From fd4ae1eb54ce945bab65522bac137d75721b3302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 18 Feb 2026 18:24:24 +0000 Subject: [PATCH] fix: resolve all backend golangci-lint violations (#245) --- apps/backend/.golangci.yml | 8 + apps/backend/cmd/kandev/helpers.go | 423 +++ apps/backend/cmd/kandev/main.go | 653 ++-- apps/backend/cmd/mock-agent/scenarios.go | 41 +- apps/backend/cmd/mock-agent/sequences.go | 40 +- .../internal/agent/agents/auggie_test.go | 34 +- .../internal/agent/agents/passthrough.go | 7 +- .../agent/credentials/env_provider.go | 50 +- .../internal/agent/handlers/handlers.go | 34 +- .../internal/agent/lifecycle/manager.go | 2810 ---------------- .../agent/lifecycle/manager_boot_test.go | 218 ++ .../agent/lifecycle/manager_events.go | 352 ++ .../agent/lifecycle/manager_events_test.go | 560 ++++ .../agent/lifecycle/manager_execution.go | 247 ++ .../agent/lifecycle/manager_interaction.go | 443 +++ .../agent/lifecycle/manager_launch.go | 391 +++ .../agent/lifecycle/manager_lifecycle.go | 312 ++ .../agent/lifecycle/manager_lifecycle_test.go | 127 + .../agent/lifecycle/manager_passthrough.go | 575 ++++ .../lifecycle/manager_passthrough_test.go | 224 ++ .../agent/lifecycle/manager_profile.go | 150 + .../agent/lifecycle/manager_runtime.go | 58 + .../agent/lifecycle/manager_startup.go | 256 ++ .../agent/lifecycle/manager_streaming.go | 196 ++ .../internal/agent/lifecycle/manager_test.go | 1097 ------- .../agent/lifecycle/profile_resolver_test.go | 52 +- .../internal/agent/lifecycle/session.go | 236 +- .../agent/lifecycle/session_history.go | 9 +- .../internal/agent/lifecycle/session_test.go | 37 +- .../internal/agent/lifecycle/streams.go | 108 +- .../internal/agent/mcpconfig/resolve.go | 163 +- .../internal/agent/registry/provider.go | 33 +- .../agent/settings/controller/agent_config.go | 298 ++ .../agent/settings/controller/agent_crud.go | 176 + .../settings/controller/agent_discovery.go | 357 +++ .../agent/settings/controller/controller.go | 871 ----- .../agent/settings/controller/profile_crud.go | 161 + .../backend/internal/agentctl/client/agent.go | 155 +- .../internal/agentctl/client/client.go | 754 ----- .../internal/agentctl/client/client_files.go | 235 ++ .../agentctl/client/client_process.go | 184 ++ .../internal/agentctl/client/client_shell.go | 82 + .../internal/agentctl/client/client_stream.go | 101 + .../agentctl/client/workspace_dispatch.go | 77 + .../agentctl/client/workspace_stream.go | 171 + .../agentctl/server/adapter/amp_adapter.go | 300 +- .../agentctl/server/adapter/amp_normalizer.go | 303 +- .../server/adapter/copilot_adapter.go | 362 ++- .../server/adapter/copilot_adapter_test.go | 10 +- .../server/adapter/copilot_normalizer.go | 259 +- .../server/adapter/transport/acp/adapter.go | 208 +- .../server/adapter/transport/acp/normalize.go | 63 +- .../adapter/transport/acp/normalize_test.go | 36 +- .../server/adapter/transport/codex/adapter.go | 1054 +----- .../adapter/transport/codex/codex_init.go | 154 + .../adapter/transport/codex/codex_items.go | 176 + .../transport/codex/codex_notifications.go | 258 ++ .../transport/codex/codex_permissions.go | 155 + .../adapter/transport/codex/codex_prompt.go | 227 ++ .../adapter/transport/codex/codex_session.go | 120 + .../server/adapter/transport/codex/errors.go | 120 +- .../adapter/transport/opencode/adapter.go | 463 +-- .../adapter/transport/opencode/normalize.go | 176 +- .../server/adapter/transport/shared/debug.go | 2 +- .../transport/shared/normalize_helpers.go | 9 +- .../adapter/transport/streamjson/adapter.go | 966 +----- .../adapter/transport/streamjson/normalize.go | 76 +- .../transport/streamjson/streamjson_init.go | 134 + .../streamjson/streamjson_messages.go | 291 ++ .../streamjson/streamjson_permissions.go | 162 + .../transport/streamjson/streamjson_prompt.go | 187 ++ .../transport/streamjson/streamjson_result.go | 195 ++ .../streamjson/streamjson_session.go | 64 + .../internal/agentctl/server/api/agent.go | 182 +- .../internal/agentctl/server/api/workspace.go | 74 +- .../internal/agentctl/server/config/config.go | 65 +- .../agentctl/server/instance/manager.go | 171 +- .../internal/agentctl/server/mcp/handlers.go | 144 +- .../internal/agentctl/server/mcp/server.go | 28 +- .../agentctl/server/process/cmdline.go | 70 +- .../internal/agentctl/server/process/git.go | 152 +- .../agentctl/server/process/interactive_io.go | 216 ++ .../server/process/interactive_lifecycle.go | 588 ++++ .../server/process/interactive_output.go | 272 ++ .../server/process/interactive_resize.go | 100 + .../server/process/interactive_runner.go | 1488 --------- .../server/process/interactive_runner_test.go | 1077 ------- .../server/process/interactive_shells.go | 349 ++ .../server/process/interactive_terminal.go | 30 + .../agentctl/server/process/manager.go | 295 +- .../agentctl/server/process/runner.go | 21 +- .../server/process/runner_lifecycle_test.go | 211 ++ .../server/process/runner_shells_test.go | 392 +++ .../server/process/runner_state_test.go | 394 +++ .../server/process/runner_terminal_test.go | 102 + .../server/process/workspace_files.go | 400 +++ .../server/process/workspace_git_commits.go | 112 + .../server/process/workspace_git_diff.go | 154 + .../server/process/workspace_git_poll.go | 218 ++ .../server/process/workspace_git_status.go | 219 ++ .../server/process/workspace_monitor.go | 200 ++ .../server/process/workspace_stream.go | 162 + .../server/process/workspace_tracker.go | 1382 +------- .../agentctl/server/shell/session_test.go | 51 +- .../analytics/handlers/stats_handlers.go | 197 +- .../analytics/repository/sqlite/stats.go | 69 +- .../internal/clarification/handlers.go | 70 +- apps/backend/internal/debug/fixture.go | 21 +- .../internal/editors/service/service.go | 79 +- .../backend/internal/gateway/websocket/hub.go | 158 +- .../internal/gateway/websocket/lsp_handler.go | 157 +- .../gateway/websocket/terminal_handler.go | 510 +-- .../error_handling_integration_test.go | 66 + .../internal/integration/integration_test.go | 762 +---- .../multi_client_integration_test.go | 276 ++ .../integration/orchestrator_advanced_test.go | 751 +++++ .../integration/orchestrator_basic_test.go | 174 + .../orchestrator_interaction_test.go | 444 +++ .../internal/integration/orchestrator_test.go | 2326 -------------- .../simulated_agent_manager_test.go | 400 +++ .../integration/task_integration_test.go | 324 ++ .../internal/integration/test_server_test.go | 402 +++ .../integration/workflow_integration_test.go | 101 + .../internal/integration/ws_client_test.go | 198 ++ .../lsp/installer/github_release_strategy.go | 62 +- .../backend/internal/mcp/handlers/handlers.go | 83 +- .../providers/system_provider.go | 174 +- .../internal/notifications/service/service.go | 42 +- .../internal/orchestrator/event_handlers.go | 1818 +---------- .../orchestrator/event_handlers_agent.go | 377 +++ .../orchestrator/event_handlers_git.go | 526 +++ .../orchestrator/event_handlers_streaming.go | 467 +++ .../orchestrator/event_handlers_workflow.go | 496 +++ .../orchestrator/executor/executor.go | 1244 +------- .../orchestrator/executor/executor_execute.go | 441 +++ .../executor/executor_interaction.go | 343 ++ .../orchestrator/executor/executor_resume.go | 468 +++ .../orchestrator/executor/executor_state.go | 153 + .../orchestrator/scheduler/scheduler_test.go | 2 +- apps/backend/internal/orchestrator/service.go | 393 +-- .../internal/orchestrator/task_operations.go | 241 +- .../internal/prompts/handlers/handlers.go | 3 +- apps/backend/internal/prompts/store/sqlite.go | 60 +- .../task/handlers/environment_handlers.go | 25 +- .../task/handlers/executor_handlers.go | 25 +- .../task/handlers/message_handlers.go | 358 ++- .../task/handlers/process_handlers.go | 377 ++- .../task/handlers/repository_handlers.go | 2 +- .../task/handlers/task_git_handlers.go | 169 + .../internal/task/handlers/task_handlers.go | 990 ------ .../task/handlers/task_http_handlers.go | 434 +++ .../task/handlers/task_plan_handlers.go | 130 + .../task/handlers/task_ws_handlers.go | 290 ++ .../task/handlers/workflow_handlers.go | 22 +- .../task/handlers/workspace_handlers.go | 25 +- .../internal/task/handlers/ws_helpers.go | 67 + .../repository/archive_repository_test.go | 243 ++ .../repository/message_repository_test.go | 237 ++ .../task/repository/repository_test.go | 91 + .../repository/session_repository_test.go | 402 +++ .../internal/task/repository/sqlite/base.go | 122 +- .../task/repository/sqlite/defaults.go | 256 +- .../task/repository/sqlite/git_snapshots.go | 52 +- .../task/repository/sqlite/message.go | 97 +- .../task/repository/sqlite/session.go | 570 +--- .../repository/sqlite/session_workflow.go | 43 + .../internal/task/repository/sqlite/task.go | 122 +- .../internal/task/repository/sqlite_test.go | 1390 -------- .../task/repository/task_repository_test.go | 359 +++ .../repository/workflow_repository_test.go | 103 + .../internal/task/service/plan_service.go | 5 +- .../task/service/repository_discovery.go | 347 +- .../task/service/repository_discovery_test.go | 4 +- apps/backend/internal/task/service/service.go | 2835 +---------------- .../internal/task/service/service_events.go | 260 ++ .../internal/task/service/service_messages.go | 629 ++++ .../internal/task/service/service_requests.go | 187 ++ .../task/service/service_resources.go | 606 ++++ .../internal/task/service/service_sessions.go | 76 + .../internal/task/service/service_tasks.go | 527 +++ .../internal/task/service/service_test.go | 2 +- .../internal/task/service/service_turns.go | 242 ++ .../internal/task/service/service_workflow.go | 391 +++ apps/backend/internal/user/service/service.go | 57 +- .../internal/workflow/handlers/handlers.go | 70 +- .../internal/workflow/handlers/ws_helpers.go | 46 + .../internal/workflow/repository/sqlite.go | 62 +- apps/backend/internal/worktree/manager.go | 293 +- .../worktree/script_message_handler.go | 168 +- apps/backend/internal/worktree/store.go | 97 +- apps/backend/pkg/claudecode/types_test.go | 7 +- apps/backend/pkg/codex/client.go | 67 +- apps/backend/pkg/opencode/client_test.go | 6 +- apps/backend/pkg/opencode/types_test.go | 6 +- 194 files changed, 30191 insertions(+), 28093 deletions(-) create mode 100644 apps/backend/cmd/kandev/helpers.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_boot_test.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_events.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_events_test.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_execution.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_interaction.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_launch.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_lifecycle.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_lifecycle_test.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_passthrough.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_passthrough_test.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_profile.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_runtime.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_startup.go create mode 100644 apps/backend/internal/agent/lifecycle/manager_streaming.go create mode 100644 apps/backend/internal/agent/settings/controller/agent_config.go create mode 100644 apps/backend/internal/agent/settings/controller/agent_crud.go create mode 100644 apps/backend/internal/agent/settings/controller/agent_discovery.go create mode 100644 apps/backend/internal/agent/settings/controller/profile_crud.go create mode 100644 apps/backend/internal/agentctl/client/client_files.go create mode 100644 apps/backend/internal/agentctl/client/client_process.go create mode 100644 apps/backend/internal/agentctl/client/client_shell.go create mode 100644 apps/backend/internal/agentctl/client/client_stream.go create mode 100644 apps/backend/internal/agentctl/client/workspace_dispatch.go create mode 100644 apps/backend/internal/agentctl/client/workspace_stream.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/codex/codex_init.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/codex/codex_items.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/codex/codex_notifications.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/codex/codex_permissions.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/codex/codex_session.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_session.go create mode 100644 apps/backend/internal/agentctl/server/process/interactive_io.go create mode 100644 apps/backend/internal/agentctl/server/process/interactive_lifecycle.go create mode 100644 apps/backend/internal/agentctl/server/process/interactive_output.go create mode 100644 apps/backend/internal/agentctl/server/process/interactive_resize.go delete mode 100644 apps/backend/internal/agentctl/server/process/interactive_runner_test.go create mode 100644 apps/backend/internal/agentctl/server/process/interactive_shells.go create mode 100644 apps/backend/internal/agentctl/server/process/interactive_terminal.go create mode 100644 apps/backend/internal/agentctl/server/process/runner_lifecycle_test.go create mode 100644 apps/backend/internal/agentctl/server/process/runner_shells_test.go create mode 100644 apps/backend/internal/agentctl/server/process/runner_state_test.go create mode 100644 apps/backend/internal/agentctl/server/process/runner_terminal_test.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_files.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_git_commits.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_git_diff.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_git_poll.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_git_status.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_monitor.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_stream.go create mode 100644 apps/backend/internal/integration/error_handling_integration_test.go create mode 100644 apps/backend/internal/integration/multi_client_integration_test.go create mode 100644 apps/backend/internal/integration/orchestrator_advanced_test.go create mode 100644 apps/backend/internal/integration/orchestrator_basic_test.go create mode 100644 apps/backend/internal/integration/orchestrator_interaction_test.go delete mode 100644 apps/backend/internal/integration/orchestrator_test.go create mode 100644 apps/backend/internal/integration/simulated_agent_manager_test.go create mode 100644 apps/backend/internal/integration/task_integration_test.go create mode 100644 apps/backend/internal/integration/test_server_test.go create mode 100644 apps/backend/internal/integration/workflow_integration_test.go create mode 100644 apps/backend/internal/integration/ws_client_test.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_agent.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_git.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_streaming.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_workflow.go create mode 100644 apps/backend/internal/orchestrator/executor/executor_execute.go create mode 100644 apps/backend/internal/orchestrator/executor/executor_interaction.go create mode 100644 apps/backend/internal/orchestrator/executor/executor_resume.go create mode 100644 apps/backend/internal/orchestrator/executor/executor_state.go create mode 100644 apps/backend/internal/task/handlers/task_git_handlers.go create mode 100644 apps/backend/internal/task/handlers/task_http_handlers.go create mode 100644 apps/backend/internal/task/handlers/task_plan_handlers.go create mode 100644 apps/backend/internal/task/handlers/task_ws_handlers.go create mode 100644 apps/backend/internal/task/handlers/ws_helpers.go create mode 100644 apps/backend/internal/task/repository/archive_repository_test.go create mode 100644 apps/backend/internal/task/repository/message_repository_test.go create mode 100644 apps/backend/internal/task/repository/repository_test.go create mode 100644 apps/backend/internal/task/repository/session_repository_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/session_workflow.go delete mode 100644 apps/backend/internal/task/repository/sqlite_test.go create mode 100644 apps/backend/internal/task/repository/task_repository_test.go create mode 100644 apps/backend/internal/task/repository/workflow_repository_test.go create mode 100644 apps/backend/internal/task/service/service_events.go create mode 100644 apps/backend/internal/task/service/service_messages.go create mode 100644 apps/backend/internal/task/service/service_requests.go create mode 100644 apps/backend/internal/task/service/service_resources.go create mode 100644 apps/backend/internal/task/service/service_sessions.go create mode 100644 apps/backend/internal/task/service/service_tasks.go create mode 100644 apps/backend/internal/task/service/service_turns.go create mode 100644 apps/backend/internal/task/service/service_workflow.go create mode 100644 apps/backend/internal/workflow/handlers/ws_helpers.go diff --git a/apps/backend/.golangci.yml b/apps/backend/.golangci.yml index 55bee3d7f6..2f6069064c 100644 --- a/apps/backend/.golangci.yml +++ b/apps/backend/.golangci.yml @@ -14,6 +14,7 @@ linters: - unconvert - dupword - maintidx + - revive settings: funlen: @@ -34,6 +35,13 @@ linters: max-func-lines: 30 maintidx: under: 15 + revive: + rules: + - name: file-length-limit + arguments: + - max: 800 + skip-comments: true + skip-blank-lines: true exclusions: rules: diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go new file mode 100644 index 0000000000..896ff8711b --- /dev/null +++ b/apps/backend/cmd/kandev/helpers.go @@ -0,0 +1,423 @@ +package main + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/docker" + "github.com/kandev/kandev/internal/agent/lifecycle" + agenthandlers "github.com/kandev/kandev/internal/agent/handlers" + agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" + analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" + analyticshandlers "github.com/kandev/kandev/internal/analytics/handlers" + "github.com/kandev/kandev/internal/clarification" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + debughandlers "github.com/kandev/kandev/internal/debug" + editorcontroller "github.com/kandev/kandev/internal/editors/controller" + editorhandlers "github.com/kandev/kandev/internal/editors/handlers" + "github.com/kandev/kandev/internal/events/bus" + gateways "github.com/kandev/kandev/internal/gateway/websocket" + mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" + notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" + notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" + "github.com/kandev/kandev/internal/orchestrator" + promptcontroller "github.com/kandev/kandev/internal/prompts/controller" + prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + taskcontroller "github.com/kandev/kandev/internal/task/controller" + taskhandlers "github.com/kandev/kandev/internal/task/handlers" + taskservice "github.com/kandev/kandev/internal/task/service" + usercontroller "github.com/kandev/kandev/internal/user/controller" + userhandlers "github.com/kandev/kandev/internal/user/handlers" + workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" + workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// initDockerClient initializes the Docker client and verifies connectivity. +// Returns nil if Docker is unavailable or the connection check fails. +func initDockerClient(ctx context.Context, cfg *config.Config, log *logger.Logger) *docker.Client { + dockerClient, err := docker.NewClient(cfg.Docker, log) + if err != nil { + log.Warn("Failed to initialize Docker client - agent features will be disabled", zap.Error(err)) + return nil + } + if err := dockerClient.Ping(ctx); err != nil { + log.Warn("Docker daemon not available - agent features will be disabled", zap.Error(err)) + _ = dockerClient.Close() + return nil + } + log.Info("Connected to Docker daemon") + return dockerClient +} + +// buildSessionDataProvider constructs the session data provider function used by the WebSocket hub +// to send initial data (git status, context window, available commands) when a client subscribes. +func buildSessionDataProvider(taskRepo repository.Repository, lifecycleMgr *lifecycle.Manager, log *logger.Logger) func(context.Context, string) ([]*ws.Message, error) { + return func(ctx context.Context, sessionID string) ([]*ws.Message, error) { + session, err := taskRepo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, nil // Session not found, no data to send + } + + var result []*ws.Message + result = appendGitStatusMessage(ctx, taskRepo, sessionID, session, result) + result = appendContextWindowMessage(sessionID, session, result) + result = appendAvailableCommandsMessage(sessionID, session, lifecycleMgr, result) + return result, nil + } +} + +// appendGitStatusMessage adds a git status notification from the latest snapshot to result. +func appendGitStatusMessage(ctx context.Context, taskRepo repository.Repository, sessionID string, session *models.TaskSession, result []*ws.Message) []*ws.Message { + latestSnapshot, err := taskRepo.GetLatestGitSnapshot(ctx, sessionID) + if err != nil || latestSnapshot == nil { + return result + } + metadata := latestSnapshot.Metadata + gitStatusData := map[string]interface{}{ + "session_id": sessionID, + "task_id": session.TaskID, + "branch": latestSnapshot.Branch, + "remote_branch": latestSnapshot.RemoteBranch, + "ahead": latestSnapshot.Ahead, + "behind": latestSnapshot.Behind, + "files": latestSnapshot.Files, + "modified": metadata["modified"], + "added": metadata["added"], + "deleted": metadata["deleted"], + "untracked": metadata["untracked"], + "renamed": metadata["renamed"], + "timestamp": metadata["timestamp"], + } + notification, err := ws.NewNotification("session.git.status", gitStatusData) + if err == nil { + result = append(result, notification) + } + return result +} + +// appendContextWindowMessage adds a context window notification to result if available. +func appendContextWindowMessage(sessionID string, session *models.TaskSession, result []*ws.Message) []*ws.Message { + if session.Metadata == nil { + return result + } + contextWindow, ok := session.Metadata["context_window"] + if !ok { + return result + } + notification, err := ws.NewNotification(ws.ActionSessionStateChanged, map[string]interface{}{ + "session_id": sessionID, + "task_id": session.TaskID, + "new_state": string(session.State), + "metadata": map[string]interface{}{ + "context_window": contextWindow, + }, + }) + if err == nil { + result = append(result, notification) + } + return result +} + +// appendAvailableCommandsMessage adds available slash commands notification to result if any. +func appendAvailableCommandsMessage(sessionID string, session *models.TaskSession, lifecycleMgr *lifecycle.Manager, result []*ws.Message) []*ws.Message { + if lifecycleMgr == nil { + return result + } + commands := lifecycleMgr.GetAvailableCommandsForSession(sessionID) + if len(commands) == 0 { + return result + } + notification, err := ws.NewNotification(ws.ActionSessionAvailableCommands, map[string]interface{}{ + "session_id": sessionID, + "task_id": session.TaskID, + "available_commands": commands, + }) + if err == nil { + result = append(result, notification) + } + return result +} + +// newMessageAddedHandler returns an event bus handler that broadcasts message.added events +// to WebSocket subscribers. +func newMessageAddedHandler(gateway *gateways.Gateway, log *logger.Logger) func(context.Context, *bus.Event) error { + return func(ctx context.Context, event *bus.Event) error { + data, ok := event.Data.(map[string]interface{}) + if !ok { + return nil + } + taskSessionID, _ := data["session_id"].(string) + taskID, _ := data["task_id"].(string) + if taskSessionID == "" { + return nil + } + payload := map[string]interface{}{ + "task_id": taskID, + "session_id": taskSessionID, + "message_id": data["message_id"], + "author_type": data["author_type"], + "author_id": data["author_id"], + "content": data["content"], + "type": data["type"], + "requests_input": data["requests_input"], + "created_at": data["created_at"], + } + if metadata, ok := data["metadata"]; ok && metadata != nil { + payload["metadata"] = metadata + } + notification, err := ws.NewNotification(ws.ActionSessionMessageAdded, payload) + if err != nil { + log.Error("Failed to create message.added notification", zap.Error(err)) + return nil + } + gateway.Hub.BroadcastToSession(taskSessionID, notification) + return nil + } +} + +// newMessageUpdatedHandler returns an event bus handler that broadcasts message.updated events +// to WebSocket subscribers. +func newMessageUpdatedHandler(gateway *gateways.Gateway, log *logger.Logger) func(context.Context, *bus.Event) error { + return func(ctx context.Context, event *bus.Event) error { + data, ok := event.Data.(map[string]interface{}) + if !ok { + return nil + } + taskSessionID, _ := data["session_id"].(string) + taskID, _ := data["task_id"].(string) + if taskSessionID == "" { + log.Warn("message.updated event has no session_id, skipping") + return nil + } + payload := map[string]interface{}{ + "message_id": data["message_id"], + "session_id": taskSessionID, + "task_id": taskID, + "author_type": data["author_type"], + "author_id": data["author_id"], + "content": data["content"], + "type": data["type"], + "requests_input": data["requests_input"], + "created_at": data["created_at"], + } + if metadata, ok := data["metadata"]; ok && metadata != nil { + payload["metadata"] = metadata + } + notification, err := ws.NewNotification(ws.ActionSessionMessageUpdated, payload) + if err != nil { + log.Error("Failed to create message.updated notification", zap.Error(err)) + return nil + } + gateway.Hub.BroadcastToSession(taskSessionID, notification) + return nil + } +} + +// newSessionStateChangedHandler returns an event bus handler that broadcasts +// task_session.state_changed events to WebSocket subscribers. +func newSessionStateChangedHandler(gateway *gateways.Gateway, log *logger.Logger) func(context.Context, *bus.Event) error { + return func(ctx context.Context, event *bus.Event) error { + data, ok := event.Data.(map[string]interface{}) + if !ok { + return nil + } + taskSessionID, _ := data["session_id"].(string) + if taskSessionID == "" { + return nil + } + notification, err := ws.NewNotification(ws.ActionSessionStateChanged, data) + if err != nil { + log.Error("Failed to create task_session.state_changed notification", zap.Error(err)) + return nil + } + gateway.Hub.BroadcastToSession(taskSessionID, notification) + return nil + } +} + +// routeParams holds all dependencies needed for HTTP and WebSocket route registration. +type routeParams struct { + router *gin.Engine + gateway *gateways.Gateway + taskSvc *taskservice.Service + taskRepo repository.Repository + analyticsRepo analyticsrepository.Repository + orchestratorSvc *orchestrator.Service + lifecycleMgr *lifecycle.Manager + eventBus bus.EventBus + services *Services + agentSettingsController *agentsettingscontroller.Controller + userCtrl *usercontroller.Controller + notificationCtrl *notificationcontroller.Controller + editorCtrl *editorcontroller.Controller + promptCtrl *promptcontroller.Controller + msgCreator *messageCreatorAdapter + log *logger.Logger +} + +// registerRoutes sets up all HTTP and WebSocket routes on the given router. +func registerRoutes(p routeParams) { + workspaceController := taskcontroller.NewWorkspaceController(p.taskSvc) + taskWorkflowController := taskcontroller.NewWorkflowController(p.taskSvc) + taskWorkflowController.SetWorkflowStepLister(p.services.Workflow) + taskController := taskcontroller.NewTaskController(p.taskSvc) + messageController := taskcontroller.NewMessageController(p.taskSvc) + repositoryController := taskcontroller.NewRepositoryController(p.taskSvc) + executorController := taskcontroller.NewExecutorController(p.taskSvc) + environmentController := taskcontroller.NewEnvironmentController(p.taskSvc) + workflowCtrl := workflowcontroller.NewController(p.services.Workflow) + planService := taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log) + clarificationStore := clarification.NewStore(10 * time.Minute) + + p.gateway.SetupRoutes(p.router) + registerTaskRoutes(p, workspaceController, taskWorkflowController, taskController, + messageController, repositoryController, executorController, environmentController, planService) + registerSecondaryRoutes(p, workflowCtrl, workspaceController, taskWorkflowController, + taskController, workflowCtrl, clarificationStore, planService) + + p.router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"}) + }) +} + +// registerTaskRoutes registers all task-related HTTP and WebSocket routes. +func registerTaskRoutes( + p routeParams, + workspaceController *taskcontroller.WorkspaceController, + taskWorkflowController *taskcontroller.WorkflowController, + taskController *taskcontroller.TaskController, + messageController *taskcontroller.MessageController, + repositoryController *taskcontroller.RepositoryController, + executorController *taskcontroller.ExecutorController, + environmentController *taskcontroller.EnvironmentController, + planService *taskservice.PlanService, +) { + taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, workspaceController, p.log) + taskhandlers.RegisterWorkflowRoutes(p.router, p.gateway.Dispatcher, taskWorkflowController, p.log) + taskhandlers.RegisterTaskRoutes(p.router, p.gateway.Dispatcher, taskController, p.orchestratorSvc, p.taskRepo, planService, p.log) + taskhandlers.RegisterRepositoryRoutes(p.router, p.gateway.Dispatcher, repositoryController, p.log) + taskhandlers.RegisterExecutorRoutes(p.router, p.gateway.Dispatcher, executorController, p.log) + taskhandlers.RegisterEnvironmentRoutes(p.router, p.gateway.Dispatcher, environmentController, p.log) + taskhandlers.RegisterMessageRoutes( + p.router, p.gateway.Dispatcher, messageController, taskController, + &orchestratorWrapper{svc: p.orchestratorSvc}, p.log, + ) + taskhandlers.RegisterProcessRoutes(p.router, p.taskSvc, p.lifecycleMgr, p.log) + analyticshandlers.RegisterStatsRoutes(p.router, p.analyticsRepo, p.log) + agenthandlers.RegisterShellRoutes(p.router, p.lifecycleMgr, p.log) + p.log.Debug("Registered Task Service handlers (HTTP + WebSocket)") +} + +// registerSecondaryRoutes registers workflow, agent settings, user, notification, editor, +// prompt, clarification, MCP, and debug routes. +func registerSecondaryRoutes( + p routeParams, + workflowCtrl *workflowcontroller.Controller, + workspaceController *taskcontroller.WorkspaceController, + taskWorkflowController *taskcontroller.WorkflowController, + taskController *taskcontroller.TaskController, + wfCtrl *workflowcontroller.Controller, + clarificationStore *clarification.Store, + planService *taskservice.PlanService, +) { + workflowhandlers.RegisterRoutes(p.router, p.gateway.Dispatcher, workflowCtrl, p.log) + p.log.Info("Registered Workflow handlers (HTTP + WebSocket)") + + agentsettingshandlers.RegisterRoutes(p.router, p.agentSettingsController, p.gateway.Hub, p.log) + p.log.Debug("Registered Agent Settings handlers (HTTP)") + + userhandlers.RegisterRoutes(p.router, p.gateway.Dispatcher, p.userCtrl, p.log) + p.log.Debug("Registered User handlers (HTTP + WebSocket)") + + notificationhandlers.RegisterRoutes(p.router, p.notificationCtrl, p.log) + p.log.Debug("Registered Notification handlers (HTTP)") + + editorhandlers.RegisterRoutes(p.router, p.editorCtrl, p.log) + p.log.Debug("Registered Editors handlers (HTTP)") + + prompthandlers.RegisterRoutes(p.router, p.promptCtrl, p.log) + p.log.Debug("Registered Prompts handlers (HTTP)") + + clarification.RegisterRoutes(p.router, clarificationStore, p.gateway.Hub, p.msgCreator, p.taskRepo, p.log) + p.log.Debug("Registered Clarification handlers (HTTP)") + + registerMCPAndDebugRoutes(p, workspaceController, taskWorkflowController, taskController, wfCtrl, clarificationStore, planService) +} + +// registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler. +func registerMCPAndDebugRoutes( + p routeParams, + workspaceController *taskcontroller.WorkspaceController, + taskWorkflowController *taskcontroller.WorkflowController, + taskController *taskcontroller.TaskController, + wfCtrl *workflowcontroller.Controller, + clarificationStore *clarification.Store, + planService *taskservice.PlanService, +) { + mcpHandlers := mcphandlers.NewHandlers( + workspaceController, taskWorkflowController, taskController, wfCtrl, + clarificationStore, p.msgCreator, p.taskRepo, p.taskRepo, p.eventBus, planService, p.log, + ) + mcpHandlers.RegisterHandlers(p.gateway.Dispatcher) + p.log.Debug("Registered MCP handlers (WebSocket)") + + p.lifecycleMgr.SetMCPHandler(p.gateway.Dispatcher) + p.log.Debug("MCP handler configured for agent lifecycle manager") + + debughandlers.RegisterRoutes(p.router, p.log) + p.log.Debug("Registered Debug handlers (HTTP)") +} + +// runGracefulShutdown gracefully stops all services and runs cleanups. +func runGracefulShutdown( + server *http.Server, + orchestratorSvc *orchestrator.Service, + lifecycleMgr *lifecycle.Manager, + runCleanups func(), + log *logger.Logger, +) { + log.Info("Shutting down Kandev...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Error("HTTP server shutdown error", zap.Error(err)) + } + + if err := orchestratorSvc.Stop(); err != nil { + log.Error("Orchestrator stop error", zap.Error(err)) + } + + stopLifecycleManager(lifecycleMgr, log) + runCleanups() + + log.Info("Kandev stopped") + _ = log.Sync() +} + +// stopLifecycleManager gracefully stops all agents and the lifecycle manager. +func stopLifecycleManager(lifecycleMgr *lifecycle.Manager, log *logger.Logger) { + if lifecycleMgr == nil { + return + } + log.Info("Stopping agents gracefully...") + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := lifecycleMgr.StopAllAgents(stopCtx); err != nil { + log.Error("Graceful agent stop error", zap.Error(err)) + } + stopCancel() + + if err := lifecycleMgr.Stop(); err != nil { + log.Error("Lifecycle manager stop error", zap.Error(err)) + } +} diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 6c971929c3..6787f213b6 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -11,7 +11,6 @@ import ( "os" "os/signal" "syscall" - "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/httpmw" @@ -25,46 +24,25 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + // Agent infrastructure + "github.com/kandev/kandev/internal/agent/docker" + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agent/registry" + agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + // WebSocket gateway gateways "github.com/kandev/kandev/internal/gateway/websocket" - // Agent Manager packages - "github.com/kandev/kandev/internal/agent/docker" - agenthandlers "github.com/kandev/kandev/internal/agent/handlers" - agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" - editorcontroller "github.com/kandev/kandev/internal/editors/controller" - editorhandlers "github.com/kandev/kandev/internal/editors/handlers" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" - notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" promptcontroller "github.com/kandev/kandev/internal/prompts/controller" - prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" - - "github.com/kandev/kandev/internal/clarification" - - // Debug handlers - debughandlers "github.com/kandev/kandev/internal/debug" - - // MCP handlers - mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" - - // Task Service packages - analyticshandlers "github.com/kandev/kandev/internal/analytics/handlers" - taskcontroller "github.com/kandev/kandev/internal/task/controller" - taskhandlers "github.com/kandev/kandev/internal/task/handlers" - taskservice "github.com/kandev/kandev/internal/task/service" usercontroller "github.com/kandev/kandev/internal/user/controller" - userhandlers "github.com/kandev/kandev/internal/user/handlers" - // Workflow packages - workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" - workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers" + // Orchestrator + "github.com/kandev/kandev/internal/orchestrator" - // Worktree package - "github.com/kandev/kandev/internal/worktree" - - // API types - ws "github.com/kandev/kandev/pkg/websocket" + // Database + "github.com/jmoiron/sqlx" ) // Command-line flags @@ -87,24 +65,33 @@ func init() { } } +// main parses flags and delegates to realMain, using os.Exit with its return code. +// os.Exit is only called from main() before any defers are registered, so that +// deferred cleanup functions inside realMain() always execute. func main() { + os.Exit(realMain()) +} + +// realMain contains all startup logic and returns 0 on success or 1 on fatal error. +// Deferred cleanup is registered here so it always executes before realMain returns. +func realMain() int { flag.Parse() if *flagHelp { flag.Usage() - os.Exit(0) + return 0 } if *flagVersion { fmt.Println("kandev version 0.1.0") - os.Exit(0) + return 0 } // 1. Load configuration cfg, err := config.Load() if err != nil { fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) - os.Exit(1) + return 1 } // Apply command-line flag overrides (flags take precedence over config/env) @@ -122,8 +109,9 @@ func main() { }) if err != nil { fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) - os.Exit(1) + return 1 } + cleanups := make([]func() error, 0) cleanupsRan := false runCleanups := func() { @@ -148,36 +136,48 @@ func main() { log.Info("Starting Kandev (unified mode)...") + if !run(cfg, log, &cleanups, runCleanups) { + return 1 + } + return 0 +} + +// run initializes all services and runs the server. Returns false on fatal startup error. +func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCleanups func()) bool { + addCleanup := func(fn func() error) { *cleanups = append(*cleanups, fn) } + // 3. Create context with cancellation ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + addCleanup(func() error { cancel(); return nil }) // 4. Initialize event bus (in-memory for unified mode, or NATS if configured) eventBusProvider, cleanup, err := events.Provide(cfg, log) if err != nil { - log.Fatal("Failed to initialize event bus", zap.Error(err)) + log.Error("Failed to initialize event bus", zap.Error(err)) + return false } - cleanups = append(cleanups, cleanup) + addCleanup(cleanup) eventBus := eventBusProvider.Bus // 5. Initialize Docker client - dockerClient, err := docker.NewClient(cfg.Docker, log) - if err != nil { - log.Warn("Failed to initialize Docker client - agent features will be disabled", zap.Error(err)) - dockerClient = nil - } else { - if err := dockerClient.Ping(ctx); err != nil { - log.Warn("Docker daemon not available - agent features will be disabled", zap.Error(err)) - _ = dockerClient.Close() - dockerClient = nil - } else { - defer func() { - _ = dockerClient.Close() - }() - log.Info("Connected to Docker daemon") - } + dockerClient := initDockerClient(ctx, cfg, log) + if dockerClient != nil { + defer func() { _ = dockerClient.Close() }() } + return startServices(ctx, cfg, log, addCleanup, eventBus, dockerClient, runCleanups) +} + +// startServices initializes task-level services and all downstream infrastructure. +func startServices( //nolint:cyclop + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + addCleanup func(func() error), + eventBus bus.EventBus, + dockerClient *docker.Client, + runCleanups func(), +) bool { // ============================================ // TASK SERVICE // ============================================ @@ -185,67 +185,71 @@ func main() { dbConn, repos, repoCleanups, err := provideRepositories(cfg, log) if err != nil { - log.Fatal("Failed to initialize repositories", zap.Error(err)) + log.Error("Failed to initialize repositories", zap.Error(err)) + return false + } + for _, c := range repoCleanups { + addCleanup(c) } - cleanups = append(cleanups, repoCleanups...) services, agentSettingsController, err := provideServices(cfg, log, repos, eventBus) if err != nil { - log.Fatal("Failed to initialize services", zap.Error(err)) + log.Error("Failed to initialize services", zap.Error(err)) + return false } - taskRepo := repos.Task - analyticsRepo := repos.Analytics - agentSettingsRepo := repos.AgentSettings - notificationRepo := repos.Notification - taskSvc := services.Task - userSvc := services.User - editorSvc := services.Editor - promptSvc := services.Prompts log.Info("Task Service initialized") - userCtrl := usercontroller.NewController(userSvc) - var gateway *gateways.Gateway - var notificationCtrl *notificationcontroller.Controller - editorCtrl := editorcontroller.NewController(editorSvc) - promptCtrl := promptcontroller.NewController(promptSvc) - - if err := runInitialAgentSetup(ctx, userSvc, agentSettingsController, log); err != nil { + if err := runInitialAgentSetup(ctx, services.User, agentSettingsController, log); err != nil { log.Warn("Failed to run initial agent setup", zap.Error(err)) } - - // ACP messages are now stored as comments in the task_comments table - // The comment system provides unified storage for all task-related communication log.Info("ACP messages will be stored as comments") // ============================================ // AGENTCTL LAUNCHER (for standalone mode) // ============================================ - var agentctlCleanup func() error - agentctlCleanup, err = provideAgentctlLauncher(ctx, cfg, log) + agentctlCleanup, err := provideAgentctlLauncher(ctx, cfg, log) if err != nil { - log.Fatal("Failed to start agentctl subprocess", zap.Error(err)) + log.Error("Failed to start agentctl subprocess", zap.Error(err)) + return false } if agentctlCleanup != nil { - cleanups = append(cleanups, agentctlCleanup) + addCleanup(agentctlCleanup) defer func() { if r := recover(); r != nil { log.Error("panic recovered, stopping agentctl", zap.Any("panic", r)) - if err := agentctlCleanup(); err != nil { - log.Error("failed to stop agentctl on panic", zap.Error(err)) + if stopErr := agentctlCleanup(); stopErr != nil { + log.Error("failed to stop agentctl on panic", zap.Error(stopErr)) } panic(r) } }() } + return startAgentInfrastructure(ctx, cfg, log, addCleanup, eventBus, dockerClient, dbConn, repos, services, agentSettingsController, runCleanups) +} + +// startAgentInfrastructure initializes the agent lifecycle manager, worktree, orchestrator, +// gateway, and HTTP server. +func startAgentInfrastructure( + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + addCleanup func(func() error), + eventBus bus.EventBus, + dockerClient *docker.Client, + dbConn *sqlx.DB, + repos *Repositories, + services *Services, + agentSettingsController *agentsettingscontroller.Controller, + runCleanups func(), +) bool { // ============================================ // AGENT MANAGER // ============================================ - // Note: MCP server is now embedded in agentctl (co-located with agents) - // and tunnels tool calls back to the backend via WebSocket. - lifecycleMgr, agentRegistry, err := provideLifecycleManager(ctx, cfg, log, eventBus, dockerClient, agentSettingsRepo) + lifecycleMgr, agentRegistry, err := provideLifecycleManager(ctx, cfg, log, eventBus, dockerClient, repos.AgentSettings) if err != nil { - log.Fatal("Failed to initialize agent manager", zap.Error(err)) + log.Error("Failed to initialize agent manager", zap.Error(err)) + return false } // ============================================ @@ -253,254 +257,121 @@ func main() { // ============================================ log.Info("Initializing Worktree Manager...") - var worktreeCleanup func() error - var worktreeRecreator *worktree.Recreator - _, worktreeRecreator, worktreeCleanup, err = provideWorktreeManager(dbConn, cfg, log, lifecycleMgr, taskSvc) + _, worktreeRecreator, worktreeCleanup, err := provideWorktreeManager(dbConn, cfg, log, lifecycleMgr, services.Task) if err != nil { - log.Fatal("Failed to initialize worktree manager", zap.Error(err)) + log.Error("Failed to initialize worktree manager", zap.Error(err)) + return false } - cleanups = append(cleanups, worktreeCleanup) + addCleanup(worktreeCleanup) log.Info("Worktree Manager initialized", zap.Bool("enabled", cfg.Worktree.Enabled), zap.String("base_path", cfg.Worktree.BasePath)) - // Set task service as workspace info provider for session recovery - if lifecycleMgr != nil { - lifecycleMgr.SetWorkspaceInfoProvider(taskSvc) - log.Info("Workspace info provider configured for session recovery") - } + lifecycleMgr.SetWorkspaceInfoProvider(services.Task) + log.Info("Workspace info provider configured for session recovery") // ============================================ // ORCHESTRATOR // ============================================ log.Info("Initializing Orchestrator...") - orchestratorSvc, msgCreator, err := provideOrchestrator(log, eventBus, taskRepo, taskSvc, userSvc, lifecycleMgr, agentRegistry, services.Workflow, worktreeRecreator) + orchestratorSvc, msgCreator, err := provideOrchestrator(log, eventBus, repos.Task, services.Task, services.User, + lifecycleMgr, agentRegistry, services.Workflow, worktreeRecreator) if err != nil { - log.Fatal("Failed to initialize orchestrator", zap.Error(err)) + log.Error("Failed to initialize orchestrator", zap.Error(err)) + return false } + return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, + agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, runCleanups) +} + +// startGatewayAndServe sets up the WebSocket gateway, HTTP routes, starts the server, +// and blocks until a shutdown signal. +func startGatewayAndServe( + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + eventBus bus.EventBus, + repos *Repositories, + services *Services, + agentSettingsController *agentsettingscontroller.Controller, + lifecycleMgr *lifecycle.Manager, + agentRegistry *registry.Registry, + orchestratorSvc *orchestrator.Service, + msgCreator *messageCreatorAdapter, + runCleanups func(), +) bool { // ============================================ - // WEBSOCKET GATEWAY (All communication via WebSocket) + // WEBSOCKET GATEWAY // ============================================ log.Info("Initializing WebSocket Gateway...") - gateway, _, notificationCtrl, err = provideGateway( - ctx, - log, - eventBus, - taskSvc, - userSvc, - orchestratorSvc, - lifecycleMgr, - agentRegistry, - notificationRepo, - taskRepo, + gateway, _, notificationCtrl, err := provideGateway( + ctx, log, eventBus, services.Task, services.User, + orchestratorSvc, lifecycleMgr, agentRegistry, + repos.Notification, repos.Task, ) if err != nil { - log.Fatal("Failed to initialize WebSocket gateway", zap.Error(err)) + log.Error("Failed to initialize WebSocket gateway", zap.Error(err)) + return false } - // Note: Hub is started and TaskNotifications/UserNotifications/TaskSessionStateChanged - // are already registered by provideGateway. - // Only register SessionStreamNotifications here as it's not part of provideGateway gateways.RegisterSessionStreamNotifications(ctx, eventBus, gateway.Hub, log) - - // Set up session data provider for session subscriptions - // Sends initial git status when a client subscribes to a session - gateway.Hub.SetSessionDataProvider(func(ctx context.Context, sessionID string) ([]*ws.Message, error) { - session, err := taskRepo.GetTaskSession(ctx, sessionID) - if err != nil { - return nil, nil // Session not found, no data to send - } - - var result []*ws.Message - - // Send git status from latest snapshot - latestSnapshot, err := taskRepo.GetLatestGitSnapshot(ctx, sessionID) - if err == nil && latestSnapshot != nil { - // Extract file lists from snapshot metadata - metadata := latestSnapshot.Metadata - modified, _ := metadata["modified"].([]interface{}) - added, _ := metadata["added"].([]interface{}) - deleted, _ := metadata["deleted"].([]interface{}) - untracked, _ := metadata["untracked"].([]interface{}) - renamed, _ := metadata["renamed"].([]interface{}) - timestamp, _ := metadata["timestamp"].(string) - - gitStatusData := map[string]interface{}{ - "session_id": sessionID, - "task_id": session.TaskID, - "branch": latestSnapshot.Branch, - "remote_branch": latestSnapshot.RemoteBranch, - "ahead": latestSnapshot.Ahead, - "behind": latestSnapshot.Behind, - "files": latestSnapshot.Files, - "modified": modified, - "added": added, - "deleted": deleted, - "untracked": untracked, - "renamed": renamed, - "timestamp": timestamp, - } - - gitStatusNotification, err := ws.NewNotification("session.git.status", gitStatusData) - if err == nil { - result = append(result, gitStatusNotification) - } - } - - // Send context window if available in session metadata - if session.Metadata != nil { - if contextWindow, ok := session.Metadata["context_window"]; ok { - notification, err := ws.NewNotification(ws.ActionSessionStateChanged, map[string]interface{}{ - "session_id": sessionID, - "task_id": session.TaskID, - "new_state": string(session.State), - "metadata": map[string]interface{}{ - "context_window": contextWindow, - }, - }) - if err == nil { - result = append(result, notification) - } - } - } - - // Send available commands from the execution (for slash command menu after page refresh) - if lifecycleMgr != nil { - commands := lifecycleMgr.GetAvailableCommandsForSession(sessionID) - if len(commands) > 0 { - notification, err := ws.NewNotification(ws.ActionSessionAvailableCommands, map[string]interface{}{ - "session_id": sessionID, - "task_id": session.TaskID, - "available_commands": commands, - }) - if err == nil { - result = append(result, notification) - } - } - } - - return result, nil - }) + gateway.Hub.SetSessionDataProvider(buildSessionDataProvider(repos.Task, lifecycleMgr, log)) log.Info("Session data provider configured for session subscriptions (git status from snapshots)") waitForAgentctlControlHealthy(ctx, cfg, log) if err := orchestratorSvc.Start(ctx); err != nil { - log.Fatal("Failed to start orchestrator", zap.Error(err)) + log.Error("Failed to start orchestrator", zap.Error(err)) + return false } log.Info("Orchestrator initialized") - // Start auto-archive background loop - taskSvc.StartAutoArchiveLoop(ctx) + services.Task.StartAutoArchiveLoop(ctx) - // Subscribe to message.added events and broadcast to WebSocket subscribers - _, err = eventBus.Subscribe(events.MessageAdded, func(ctx context.Context, event *bus.Event) error { - data, ok := event.Data.(map[string]interface{}) - if !ok { - return nil - } - taskSessionID, _ := data["session_id"].(string) - taskID, _ := data["task_id"].(string) - if taskSessionID == "" { - return nil - } - - payload := map[string]interface{}{ - "task_id": taskID, - "session_id": taskSessionID, - "message_id": data["message_id"], - "author_type": data["author_type"], - "author_id": data["author_id"], - "content": data["content"], - "type": data["type"], - "requests_input": data["requests_input"], - "created_at": data["created_at"], - } - if metadata, ok := data["metadata"]; ok && metadata != nil { - payload["metadata"] = metadata - } - notification, err := ws.NewNotification(ws.ActionSessionMessageAdded, payload) - if err != nil { - log.Error("Failed to create message.added notification", zap.Error(err)) - return nil - } - gateway.Hub.BroadcastToSession(taskSessionID, notification) - return nil - }) - if err != nil { - log.Error("Failed to subscribe to message.added events", zap.Error(err)) - } else { - log.Debug("Subscribed to message.added events for WebSocket broadcasting") - } - - // Subscribe to message.updated events and broadcast to WebSocket subscribers - _, err = eventBus.Subscribe(events.MessageUpdated, func(ctx context.Context, event *bus.Event) error { - data, ok := event.Data.(map[string]interface{}) - if !ok { - return nil - } - taskSessionID, _ := data["session_id"].(string) - taskID, _ := data["task_id"].(string) - if taskSessionID == "" { - log.Warn("message.updated event has no session_id, skipping") - return nil - } - payload := map[string]interface{}{ - "message_id": data["message_id"], - "session_id": taskSessionID, - "task_id": taskID, - "author_type": data["author_type"], - "author_id": data["author_id"], - "content": data["content"], - "type": data["type"], - "requests_input": data["requests_input"], - "created_at": data["created_at"], - } - if metadata, ok := data["metadata"]; ok && metadata != nil { - payload["metadata"] = metadata - } - notification, err := ws.NewNotification(ws.ActionSessionMessageUpdated, payload) - if err != nil { - log.Error("Failed to create message.updated notification", zap.Error(err)) - return nil - } - gateway.Hub.BroadcastToSession(taskSessionID, notification) - return nil - }) - if err != nil { - log.Error("Failed to subscribe to message.updated events", zap.Error(err)) - } else { - log.Debug("Subscribed to message.updated events for WebSocket broadcasting") - } - - // Subscribe to task_session.state_changed events and broadcast to task subscribers - _, err = eventBus.Subscribe(events.TaskSessionStateChanged, func(ctx context.Context, event *bus.Event) error { - data, ok := event.Data.(map[string]interface{}) - if !ok { - return nil - } - taskSessionID, _ := data["session_id"].(string) - if taskSessionID == "" { - return nil - } - notification, err := ws.NewNotification(ws.ActionSessionStateChanged, data) - if err != nil { - log.Error("Failed to create task_session.state_changed notification", zap.Error(err)) - return nil - } - gateway.Hub.BroadcastToSession(taskSessionID, notification) - return nil - }) - if err != nil { - log.Error("Failed to subscribe to task_session.state_changed events", zap.Error(err)) - } else { - log.Debug("Subscribed to task_session.state_changed events for WebSocket broadcasting") - } + subscribeEventBusHandlers(eventBus, gateway, log) // ============================================ - // HTTP SERVER (WebSocket + HTTP endpoints) + // HTTP SERVER // ============================================ + server := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, + lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator) + + port := cfg.Server.Port + if port == 0 { + port = 8080 + } + go func() { + log.Info("WebSocket server listening", zap.Int("port", port)) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("Server listen error", zap.Error(err)) + } + }() + + log.Info("API configured", + zap.String("websocket", "/ws"), + zap.String("health", "/health"), + zap.String("http", "/api/v1"), + ) + + awaitShutdown(server, orchestratorSvc, lifecycleMgr, runCleanups, log) + return true +} + +// buildHTTPServer creates the HTTP server with all routes registered. +func buildHTTPServer( + cfg *config.Config, + log *logger.Logger, + gateway *gateways.Gateway, + repos *Repositories, + services *Services, + agentSettingsController *agentsettingscontroller.Controller, + lifecycleMgr *lifecycle.Manager, + eventBus bus.EventBus, + orchestratorSvc *orchestrator.Service, + notificationCtrl *notificationcontroller.Controller, + msgCreator *messageCreatorAdapter, +) *http.Server { if cfg.Logging.Level != "debug" { gin.SetMode(gin.ReleaseMode) } @@ -509,124 +380,64 @@ func main() { router.Use(gin.Recovery()) router.Use(corsMiddleware()) - workspaceController := taskcontroller.NewWorkspaceController(taskSvc) - taskWorkflowController := taskcontroller.NewWorkflowController(taskSvc) - taskWorkflowController.SetWorkflowStepLister(services.Workflow) - taskController := taskcontroller.NewTaskController(taskSvc) - messageController := taskcontroller.NewMessageController(taskSvc) - repositoryController := taskcontroller.NewRepositoryController(taskSvc) - executorController := taskcontroller.NewExecutorController(taskSvc) - environmentController := taskcontroller.NewEnvironmentController(taskSvc) - workflowCtrl := workflowcontroller.NewController(services.Workflow) - - // WebSocket endpoint - primary realtime transport - gateway.SetupRoutes(router) - - // Task Service handlers (HTTP + WebSocket) - taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) - taskhandlers.RegisterWorkflowRoutes(router, gateway.Dispatcher, taskWorkflowController, log) - planService := taskservice.NewPlanService(taskRepo, eventBus, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, orchestratorSvc, taskRepo, planService, log) - taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) - taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) - taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) - taskhandlers.RegisterMessageRoutes( - router, - gateway.Dispatcher, - messageController, - taskController, - &orchestratorWrapper{svc: orchestratorSvc}, - log, - ) - taskhandlers.RegisterProcessRoutes(router, taskSvc, lifecycleMgr, log) - analyticshandlers.RegisterStatsRoutes(router, analyticsRepo, log) - agenthandlers.RegisterShellRoutes(router, lifecycleMgr, log) - log.Debug("Registered Task Service handlers (HTTP + WebSocket)") - - workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log) - log.Info("Registered Workflow handlers (HTTP + WebSocket)") - - agentsettingshandlers.RegisterRoutes(router, agentSettingsController, gateway.Hub, log) - log.Debug("Registered Agent Settings handlers (HTTP)") - - userhandlers.RegisterRoutes(router, gateway.Dispatcher, userCtrl, log) - log.Debug("Registered User handlers (HTTP + WebSocket)") - - notificationhandlers.RegisterRoutes(router, notificationCtrl, log) - log.Debug("Registered Notification handlers (HTTP)") - - editorhandlers.RegisterRoutes(router, editorCtrl, log) - log.Debug("Registered Editors handlers (HTTP)") - - prompthandlers.RegisterRoutes(router, promptCtrl, log) - log.Debug("Registered Prompts handlers (HTTP)") - - // Clarification routes for agent questions - clarificationStore := clarification.NewStore(10 * time.Minute) - clarification.RegisterRoutes(router, clarificationStore, gateway.Hub, msgCreator, taskRepo, log) - log.Debug("Registered Clarification handlers (HTTP)") - - // MCP handlers for agentctl WS tunnel (agentctl -> backend) - mcpHandlers := mcphandlers.NewHandlers( - workspaceController, - taskWorkflowController, - taskController, - workflowCtrl, - clarificationStore, - msgCreator, - taskRepo, // SessionRepository - taskRepo, // TaskRepository (same instance) - eventBus, // EventBus - planService, - log, - ) - mcpHandlers.RegisterHandlers(gateway.Dispatcher) - log.Debug("Registered MCP handlers (WebSocket)") - - // Set MCP handler for lifecycle manager to dispatch MCP requests from agents - // MCP requests flow: agent -> agentctl -> agent stream (WS) -> backend -> dispatcher - lifecycleMgr.SetMCPHandler(gateway.Dispatcher) - log.Debug("MCP handler configured for agent lifecycle manager") - - debughandlers.RegisterRoutes(router, log) - log.Debug("Registered Debug handlers (HTTP)") - - // Health check (simple HTTP for load balancers/monitoring) - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "service": "kandev", - "mode": "websocket+http", - }) + registerRoutes(routeParams{ + router: router, + gateway: gateway, + taskSvc: services.Task, + taskRepo: repos.Task, + analyticsRepo: repos.Analytics, + orchestratorSvc: orchestratorSvc, + lifecycleMgr: lifecycleMgr, + eventBus: eventBus, + services: services, + agentSettingsController: agentSettingsController, + userCtrl: usercontroller.NewController(services.User), + notificationCtrl: notificationCtrl, + editorCtrl: editorcontroller.NewController(services.Editor), + promptCtrl: promptcontroller.NewController(services.Prompts), + msgCreator: msgCreator, + log: log, }) - // Create HTTP server port := cfg.Server.Port if port == 0 { port = 8080 } - server := &http.Server{ + return &http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: router, ReadTimeout: cfg.Server.ReadTimeoutDuration(), WriteTimeout: cfg.Server.WriteTimeoutDuration(), } +} - // Start server - go func() { - log.Info("WebSocket server listening", zap.Int("port", port)) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatal("Failed to start server", zap.Error(err)) - } - }() - - // Print routes summary - log.Info("API configured", - zap.String("websocket", "/ws"), - zap.String("health", "/health"), - zap.String("http", "/api/v1"), - ) +// subscribeEventBusHandlers subscribes WebSocket broadcast handlers to the event bus. +func subscribeEventBusHandlers(eventBus bus.EventBus, gateway *gateways.Gateway, log *logger.Logger) { + if _, err := eventBus.Subscribe(events.MessageAdded, newMessageAddedHandler(gateway, log)); err != nil { + log.Error("Failed to subscribe to message.added events", zap.Error(err)) + } else { + log.Debug("Subscribed to message.added events for WebSocket broadcasting") + } + if _, err := eventBus.Subscribe(events.MessageUpdated, newMessageUpdatedHandler(gateway, log)); err != nil { + log.Error("Failed to subscribe to message.updated events", zap.Error(err)) + } else { + log.Debug("Subscribed to message.updated events for WebSocket broadcasting") + } + if _, err := eventBus.Subscribe(events.TaskSessionStateChanged, newSessionStateChangedHandler(gateway, log)); err != nil { + log.Error("Failed to subscribe to task_session.state_changed events", zap.Error(err)) + } else { + log.Debug("Subscribed to task_session.state_changed events for WebSocket broadcasting") + } +} +// awaitShutdown waits for an OS signal then performs graceful shutdown. +func awaitShutdown( + server *http.Server, + orchestratorSvc *orchestrator.Service, + lifecycleMgr *lifecycle.Manager, + runCleanups func(), + log *logger.Logger, +) { // ============================================ // GRACEFUL SHUTDOWN // ============================================ @@ -643,37 +454,5 @@ func main() { }() log.Info("Received shutdown signal", zap.String("signal", sig.String())) - - log.Info("Shutting down Kandev...") - cancel() - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - log.Error("HTTP server shutdown error", zap.Error(err)) - } - - if err := orchestratorSvc.Stop(); err != nil { - log.Error("Orchestrator stop error", zap.Error(err)) - } - - if lifecycleMgr != nil { - log.Info("Stopping agents gracefully...") - stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) - if err := lifecycleMgr.StopAllAgents(stopCtx); err != nil { - log.Error("Graceful agent stop error", zap.Error(err)) - } - stopCancel() - - if err := lifecycleMgr.Stop(); err != nil { - log.Error("Lifecycle manager stop error", zap.Error(err)) - } - } - - // Run all cleanups before exiting so logs appear before the shell prompt - runCleanups() - - log.Info("Kandev stopped") - _ = log.Sync() + runGracefulShutdown(server, orchestratorSvc, lifecycleMgr, runCleanups, log) } diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index f83baf9e69..b75bc2addd 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -241,6 +241,28 @@ func scenarioAllTools(enc *json.Encoder, scanner *bufio.Scanner) { fixedDelay(50) emitThinkingBlock(enc, "Running all tools...", "") + scenarioAllToolsReadGrep(enc, readFile, grepFile) + scenarioAllToolsEditBash(enc, scanner, editFile) + + // WebFetch + fixedDelay(50) + webID := nextToolID() + _ = enc.Encode(AssistantMsg{ + Type: TypeAssistant, + Message: AssistantBody{ + Role: "assistant", + Content: []ContentBlock{{Type: BlockToolUse, ID: webID, Name: ToolWebFetch, Input: map[string]any{"url": "https://example.com", "prompt": "Summarize"}}}, + Model: "mock-default", StopReason: "tool_use", Usage: defaultUsage(), + }, + }) + fixedDelay(50) + _ = enc.Encode(UserMsg{Type: TypeUser, Message: UserMsgBody{Role: "user", Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: webID, Content: "Example page content"}}}}) + + fixedDelay(50) + emitTextBlock(enc, "All tools scenario complete.", "") +} + +func scenarioAllToolsReadGrep(enc *json.Encoder, readFile, grepFile fileInfo) { // Read fixedDelay(50) readID := nextToolID() @@ -274,7 +296,9 @@ func scenarioAllTools(enc *json.Encoder, scanner *bufio.Scanner) { grepResults = append(grepResults, fmt.Sprintf("%s:%d: func found here", p, (i+1)*10)) } _ = enc.Encode(UserMsg{Type: TypeUser, Message: UserMsgBody{Role: "user", Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: grepID, Content: strings.Join(grepResults, "\n")}}}}) +} +func scenarioAllToolsEditBash(enc *json.Encoder, scanner *bufio.Scanner, editFile fileInfo) { // Edit (with permission) fixedDelay(50) editID := nextToolID() @@ -315,23 +339,6 @@ func scenarioAllTools(enc *json.Encoder, scanner *bufio.Scanner) { } else { emitTextBlock(enc, "Bash denied.", "") } - - // WebFetch - fixedDelay(50) - webID := nextToolID() - _ = enc.Encode(AssistantMsg{ - Type: TypeAssistant, - Message: AssistantBody{ - Role: "assistant", - Content: []ContentBlock{{Type: BlockToolUse, ID: webID, Name: ToolWebFetch, Input: map[string]any{"url": "https://example.com", "prompt": "Summarize"}}}, - Model: "mock-default", StopReason: "tool_use", Usage: defaultUsage(), - }, - }) - fixedDelay(50) - _ = enc.Encode(UserMsg{Type: TypeUser, Message: UserMsgBody{Role: "user", Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: webID, Content: "Example page content"}}}}) - - fixedDelay(50) - emitTextBlock(enc, "All tools scenario complete.", "") } // scenarioMultiTurn: minimal response for multi-turn test. diff --git a/apps/backend/cmd/mock-agent/sequences.go b/apps/backend/cmd/mock-agent/sequences.go index d1ac077c73..62b6cc3f9f 100644 --- a/apps/backend/cmd/mock-agent/sequences.go +++ b/apps/backend/cmd/mock-agent/sequences.go @@ -288,13 +288,7 @@ func emitSubagent(enc *json.Encoder, scanner *bufio.Scanner, model string) { }) randomDelay(model) - - _ = enc.Encode(SystemMsg{ - Type: TypeSystem, - SessionID: sessionID, - SessionStatus: "active", - }) - + _ = enc.Encode(SystemMsg{Type: TypeSystem, SessionID: sessionID, SessionStatus: "active"}) randomDelay(model) emitThinkingBlock(enc, "Exploring the project structure...", taskToolID) randomDelay(model) @@ -302,8 +296,11 @@ func emitSubagent(enc *json.Encoder, scanner *bufio.Scanner, model string) { paths := randomFilePaths(5) emitTextBlock(enc, fmt.Sprintf("Found %d files. The project structure looks well-organized.", len(paths)), taskToolID) randomDelay(model) + emitSubagentChildGlob(enc, taskToolID, model, paths) + emitSubagentCompletion(enc, taskToolID, model, paths) +} - // Child Glob tool +func emitSubagentChildGlob(enc *json.Encoder, taskToolID, model string, paths []string) { childToolID := nextToolID() _ = enc.Encode(AssistantMsg{ Type: TypeAssistant, @@ -312,12 +309,10 @@ func emitSubagent(enc *json.Encoder, scanner *bufio.Scanner, model string) { Role: "assistant", Content: []ContentBlock{ { - Type: BlockToolUse, - ID: childToolID, - Name: ToolGlob, - Input: map[string]any{ - "pattern": "**/*", - }, + Type: BlockToolUse, + ID: childToolID, + Name: ToolGlob, + Input: map[string]any{"pattern": "**/*"}, }, }, Model: "mock-default", @@ -325,25 +320,20 @@ func emitSubagent(enc *json.Encoder, scanner *bufio.Scanner, model string) { Usage: defaultUsage(), }, }) - randomDelay(model) - _ = enc.Encode(UserMsg{ Type: TypeUser, ParentToolUseID: taskToolID, Message: UserMsgBody{ - Role: "user", - Content: []ContentBlock{ - { - Type: BlockToolResult, - ToolUseID: childToolID, - Content: strings.Join(paths, "\n"), - }, - }, + Role: "user", + Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: childToolID, Content: strings.Join(paths, "\n")}}, }, }) - randomDelay(model) +} + +// emitSubagentCompletion emits the final text block and tool result for a subagent task. +func emitSubagentCompletion(enc *json.Encoder, taskToolID, model string, paths []string) { emitTextBlock(enc, "Project structure analysis complete.", taskToolID) randomDelay(model) diff --git a/apps/backend/internal/agent/agents/auggie_test.go b/apps/backend/internal/agent/agents/auggie_test.go index eeb4bc1340..c730dec299 100644 --- a/apps/backend/internal/agent/agents/auggie_test.go +++ b/apps/backend/internal/agent/agents/auggie_test.go @@ -4,6 +4,25 @@ import ( "testing" ) +// assertDefaultModel verifies that exactly one model has IsDefault=true and it matches wantDef. +func assertDefaultModel(t *testing.T, models []Model, wantDef string) { + t.Helper() + found := false + for _, m := range models { + if m.ID == wantDef { + if !m.IsDefault { + t.Errorf("model %q should have IsDefault=true", wantDef) + } + found = true + } else if m.IsDefault { + t.Errorf("model %q should not have IsDefault=true", m.ID) + } + } + if !found { + t.Errorf("default model %q not found in results", wantDef) + } +} + func TestAuggieParseModels(t *testing.T) { tests := []struct { name string @@ -98,20 +117,7 @@ Another random line`, } } if tt.wantDef != "" { - found := false - for _, m := range models { - if m.ID == tt.wantDef { - if !m.IsDefault { - t.Errorf("model %q should have IsDefault=true", tt.wantDef) - } - found = true - } else if m.IsDefault { - t.Errorf("model %q should not have IsDefault=true", m.ID) - } - } - if !found { - t.Errorf("default model %q not found in results", tt.wantDef) - } + assertDefaultModel(t, models, tt.wantDef) } }) } diff --git a/apps/backend/internal/agent/agents/passthrough.go b/apps/backend/internal/agent/agents/passthrough.go index 19f6542ea3..ea51b25eb7 100644 --- a/apps/backend/internal/agent/agents/passthrough.go +++ b/apps/backend/internal/agent/agents/passthrough.go @@ -18,11 +18,12 @@ func (p *StandardPassthrough) BuildPassthroughCommand(opts PassthroughOptions) C Model(p.Cfg.ModelFlag, opts.Model). Settings(p.PermSettings, opts.PermissionValues) - if opts.SessionID != "" && !p.Cfg.SessionResumeFlag.IsEmpty() { + switch { + case opts.SessionID != "" && !p.Cfg.SessionResumeFlag.IsEmpty(): b.Resume(p.Cfg.SessionResumeFlag, opts.SessionID, false) - } else if opts.Resume && !p.Cfg.ResumeFlag.IsEmpty() { + case opts.Resume && !p.Cfg.ResumeFlag.IsEmpty(): b.Flag(p.Cfg.ResumeFlag.Args()...) - } else if opts.Prompt != "" { + case opts.Prompt != "": b.Prompt(p.Cfg.PromptFlag, opts.Prompt) } diff --git a/apps/backend/internal/agent/credentials/env_provider.go b/apps/backend/internal/agent/credentials/env_provider.go index b35ece0aaa..55605e98b6 100644 --- a/apps/backend/internal/agent/credentials/env_provider.go +++ b/apps/backend/internal/agent/credentials/env_provider.go @@ -75,6 +75,25 @@ func (p *EnvProvider) GetCredential(ctx context.Context, key string) (*Credentia return nil, fmt.Errorf("credential not found: %s", key) } +// isAlreadyListed returns true if key is already present in the list. +func isAlreadyListed(key string, list []string) bool { + for _, existing := range list { + if existing == key { + return true + } + } + return false +} + +// matchesAPIKeyPattern returns true if lowerKey contains any known API key sub-string. +func matchesAPIKeyPattern(lowerKey string) bool { + return strings.Contains(lowerKey, "api_key") || + strings.Contains(lowerKey, "apikey") || + strings.Contains(lowerKey, "api-key") || + strings.Contains(lowerKey, "_token") || + strings.Contains(lowerKey, "_secret") +} + // ListAvailable returns list of available credential keys from environment func (p *EnvProvider) ListAvailable(ctx context.Context) ([]string, error) { available := make([]string, 0) @@ -102,32 +121,19 @@ func (p *EnvProvider) ListAvailable(ctx context.Context) ([]string, error) { key := parts[0] - // Skip if already in list - found := false - for _, existing := range available { - if existing == key { - found = true - break - } - } - if found { + if isAlreadyListed(key, available) { continue } - // Check for API key patterns in environment variable names - lowerKey := strings.ToLower(key) - if strings.Contains(lowerKey, "api_key") || - strings.Contains(lowerKey, "apikey") || - strings.Contains(lowerKey, "api-key") || - strings.Contains(lowerKey, "_token") || - strings.Contains(lowerKey, "_secret") { - - // Strip prefix if present - if p.prefix != "" && strings.HasPrefix(key, p.prefix) { - key = strings.TrimPrefix(key, p.prefix) - } - available = append(available, key) + if !matchesAPIKeyPattern(strings.ToLower(key)) { + continue } + + // Strip prefix if present + if p.prefix != "" && strings.HasPrefix(key, p.prefix) { + key = strings.TrimPrefix(key, p.prefix) + } + available = append(available, key) } return available, nil diff --git a/apps/backend/internal/agent/handlers/handlers.go b/apps/backend/internal/agent/handlers/handlers.go index 4dea2f3788..3bd9f06673 100644 --- a/apps/backend/internal/agent/handlers/handlers.go +++ b/apps/backend/internal/agent/handlers/handlers.go @@ -88,41 +88,47 @@ type wsAgentIDRequest struct { AgentID string `json:"agent_id"` } -func (h *Handlers) wsGetAgentStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { +// parseAgentIDRequest parses the agent_id from a WebSocket message and validates it is non-empty. +func (h *Handlers) parseAgentIDRequest(msg *ws.Message) (string, *ws.Message, error) { var req wsAgentIDRequest if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + errMsg, wsErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return "", errMsg, wsErr } if req.AgentID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_id is required", nil) + errMsg, wsErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_id is required", nil) + return "", errMsg, wsErr } + return req.AgentID, nil, nil +} - resp, err := h.controller.GetAgentStatus(ctx, dto.GetAgentStatusRequest{AgentID: req.AgentID}) +func (h *Handlers) wsGetAgentStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + agentID, errMsg, err := h.parseAgentIDRequest(msg) + if errMsg != nil || err != nil { + return errMsg, err + } + resp, err := h.controller.GetAgentStatus(ctx, dto.GetAgentStatusRequest{AgentID: agentID}) if err != nil { if err == controller.ErrAgentNotFound { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Agent not found", nil) } - h.logger.Error("failed to get agent status", zap.String("agent_id", req.AgentID), zap.Error(err)) + h.logger.Error("failed to get agent status", zap.String("agent_id", agentID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } return ws.NewResponse(msg.ID, msg.Action, resp) } func (h *Handlers) wsGetAgentLogs(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsAgentIDRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + agentID, errMsg, err := h.parseAgentIDRequest(msg) + if errMsg != nil || err != nil { + return errMsg, err } - if req.AgentID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_id is required", nil) - } - - resp, err := h.controller.GetAgentLogs(ctx, dto.GetAgentLogsRequest{AgentID: req.AgentID}) + resp, err := h.controller.GetAgentLogs(ctx, dto.GetAgentLogsRequest{AgentID: agentID}) if err != nil { if err == controller.ErrAgentNotFound { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Agent not found", nil) } - h.logger.Error("failed to get agent logs", zap.String("agent_id", req.AgentID), zap.Error(err)) + h.logger.Error("failed to get agent logs", zap.String("agent_id", agentID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } return ws.NewResponse(msg.ID, msg.Action, resp) diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 7bb77d5ad8..1c8608b4d5 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -3,32 +3,16 @@ package lifecycle import ( - "context" - "errors" - "fmt" - "path/filepath" - "strings" "sync" "time" - "github.com/google/uuid" "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/agents" - "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/agent/runtime" agentctl "github.com/kandev/kandev/internal/agentctl/client" - "github.com/kandev/kandev/internal/agentctl/server/process" - agentctltypes "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/internal/common/appctx" "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" - "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/worktree" - v1 "github.com/kandev/kandev/pkg/api/v1" ) // RuntimeFallbackPolicy controls behavior when a requested runtime is unavailable. @@ -214,2797 +198,3 @@ func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) { func (m *Manager) SetBootMessageService(svc BootMessageService) { m.bootMessageService = svc } - -// getRuntimeForExecutorType returns the appropriate runtime for the given executor type. -// If the executor type is empty or the runtime is not available, behavior depends on runtimeFallbackPolicy. -func (m *Manager) getRuntimeForExecutorType(executorType string) (Runtime, error) { - if m.runtimeRegistry == nil { - return nil, fmt.Errorf("no runtime registry configured") - } - - if executorType != "" { - runtimeName := runtime.ExecutorTypeToRuntime(models.ExecutorType(executorType)) - rt, err := m.runtimeRegistry.GetRuntime(runtimeName) - if err == nil { - return rt, nil - } - - // Handle fallback based on policy - switch m.runtimeFallbackPolicy { - case RuntimeFallbackDeny: - return nil, fmt.Errorf("runtime %s not available and fallback is denied: %w", runtimeName, err) - case RuntimeFallbackWarn: - m.logger.Warn("requested runtime not available, falling back to default", - zap.String("executor_type", executorType), - zap.String("runtime", string(runtimeName)), - zap.Error(err)) - case RuntimeFallbackAllow: - m.logger.Debug("requested runtime not available, falling back to default", - zap.String("executor_type", executorType), - zap.String("runtime", string(runtimeName))) - default: - // Default to warn behavior for backwards compatibility - m.logger.Warn("requested runtime not available, falling back to default", - zap.String("executor_type", executorType), - zap.String("runtime", string(runtimeName)), - zap.Error(err)) - } - } - - return m.runtimeRegistry.GetRuntime(runtime.NameStandalone) -} - -// getDefaultRuntime returns the default runtime (standalone). -// This is used when no executor type is specified. -func (m *Manager) getDefaultRuntime() (Runtime, error) { - if m.runtimeRegistry == nil { - return nil, fmt.Errorf("no runtime registry configured") - } - return m.runtimeRegistry.GetRuntime(runtime.NameStandalone) -} - -// 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() { - // Use detached context that respects stopCh for graceful shutdown - waitCtx, cancel := appctx.Detached(ctx, m.stopCh, 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, nil) - } - }() - - return execution, nil -} - -// EnsurePassthroughExecution ensures an execution exists for a passthrough session -// and starts the passthrough process if needed. This is called when the terminal -// handler receives a connection for a session that might need recovery after backend restart. -// -// The sessionID is required. If taskID is empty, it will be looked up from: -// 1. The existing execution (if any) -// 2. The workspace info provider -// -// Returns the execution with a running passthrough process, or an error. -func (m *Manager) EnsurePassthroughExecution(ctx context.Context, sessionID string) (*AgentExecution, error) { - // Check if execution already exists with a running passthrough process - if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { - if execution.PassthroughProcessID != "" { - return execution, nil - } - // Execution exists but no passthrough process - will try to start it - return m.resumeExistingExecution(ctx, sessionID, execution) - } - - // No execution exists - need to create one from session info - return m.createExecutionFromSessionInfo(ctx, sessionID) -} - -// resumeExistingExecution starts the passthrough process for an existing execution -// that has no running process (e.g., after backend restart). -func (m *Manager) resumeExistingExecution(ctx context.Context, sessionID string, execution *AgentExecution) (*AgentExecution, error) { - m.logger.Info("execution exists but passthrough process not running, starting", - zap.String("session_id", sessionID), - zap.String("execution_id", execution.ID)) - - if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { - return nil, fmt.Errorf("resume passthrough session %s: %w", sessionID, err) - } - - // Get updated execution with process ID - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return nil, fmt.Errorf("execution disappeared after resuming passthrough session %s", sessionID) - } - return execution, nil -} - -// createExecutionFromSessionInfo creates a new execution for a passthrough session -// when no execution exists (e.g., backend restarted and execution store was cleared). -func (m *Manager) createExecutionFromSessionInfo(ctx context.Context, sessionID string) (*AgentExecution, error) { - if m.workspaceInfoProvider == nil { - return nil, fmt.Errorf("cannot restore session %s: workspace info provider not configured", sessionID) - } - - // Get workspace info from the provider (looks up session to get taskID, workspace path, etc.) - info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, "", sessionID) - if err != nil { - return nil, fmt.Errorf("get workspace info for session %s: %w", sessionID, err) - } - - if info.WorkspacePath == "" { - return nil, fmt.Errorf("session %s has no workspace path configured", sessionID) - } - - if info.TaskID == "" { - return nil, fmt.Errorf("session %s has no associated task ID", sessionID) - } - - // Verify this session should use passthrough mode - if err := m.verifyPassthroughEnabled(ctx, sessionID, info.AgentProfileID); err != nil { - return nil, err - } - - // Create the execution - m.logger.Info("creating execution for passthrough session", - zap.String("task_id", info.TaskID), - zap.String("session_id", sessionID), - zap.String("workspace_path", info.WorkspacePath)) - - execution, err := m.createExecution(ctx, info.TaskID, info) - if err != nil { - return nil, fmt.Errorf("create execution for session %s: %w", sessionID, err) - } - - // Start the passthrough process using resume command (recovery after restart) - m.logger.Info("starting passthrough process for session", - zap.String("session_id", sessionID), - zap.String("execution_id", execution.ID)) - - if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { - return nil, fmt.Errorf("start passthrough process for session %s: %w", sessionID, err) - } - - // Get updated execution with process ID - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return nil, fmt.Errorf("execution disappeared after starting passthrough session %s", sessionID) - } - - return execution, nil -} - -// verifyPassthroughEnabled checks if the session's profile has CLI passthrough enabled. -func (m *Manager) verifyPassthroughEnabled(ctx context.Context, sessionID, profileID string) error { - if m.profileResolver == nil || profileID == "" { - return fmt.Errorf("session %s has no profile configured for passthrough mode", sessionID) - } - - profileInfo, err := m.profileResolver.ResolveProfile(ctx, profileID) - if err != nil { - m.logger.Warn("failed to resolve profile for passthrough check", - zap.String("session_id", sessionID), - zap.String("profile_id", profileID), - zap.Error(err)) - return fmt.Errorf("session %s: failed to resolve profile %s: %w", sessionID, profileID, err) - } - - if profileInfo == nil || !profileInfo.CLIPassthrough { - return fmt.Errorf("session %s is not configured for CLI passthrough mode", sessionID) - } - - return 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) { - // Get the default runtime for on-demand execution creation - rt, err := m.getDefaultRuntime() - if err != nil { - return nil, fmt.Errorf("no runtime configured: %w", err) - } - - if info.AgentID == "" { - return nil, fmt.Errorf("agent ID is required in WorkspaceInfo") - } - - executionID := uuid.New().String() - - agentConfig, ok := m.registry.Get(info.AgentID) - if !ok { - return nil, fmt.Errorf("agent type %q not found in registry", info.AgentID) - } - - req := &RuntimeCreateRequest{ - InstanceID: executionID, - TaskID: taskID, - SessionID: info.SessionID, - AgentProfileID: info.AgentProfileID, - WorkspacePath: info.WorkspacePath, - Protocol: string(agentConfig.Runtime().Protocol), - AgentConfig: agentConfig, - } - - runtimeInstance, err := rt.CreateInstance(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to create execution: %w", err) - } - - execution := runtimeInstance.ToAgentExecution(req) - execution.RuntimeName = string(rt.Name()) - - // 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", execution.RuntimeName)) - - return execution, nil -} - -// Start starts the lifecycle manager background tasks -func (m *Manager) Start(ctx context.Context) error { - if m.runtimeRegistry == nil { - m.logger.Warn("no runtime registry configured") - return nil - } - - runtimeNames := m.runtimeRegistry.List() - m.logger.Info("starting lifecycle manager", zap.Int("runtimes", len(runtimeNames))) - - // Check health of all registered runtimes - healthResults := m.runtimeRegistry.HealthCheckAll(ctx) - for name, err := range healthResults { - if err != nil { - m.logger.Warn("runtime health check failed", - zap.String("runtime", string(name)), - zap.Error(err)) - } else { - m.logger.Info("runtime is healthy", zap.String("runtime", string(name))) - } - } - - // Try to recover executions from all runtimes - recovered, err := m.runtimeRegistry.RecoverAll(ctx) - if err != nil { - m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err)) - } - if len(recovered) > 0 { - for _, ri := range recovered { - execution := &AgentExecution{ - ID: ri.InstanceID, - TaskID: ri.TaskID, - SessionID: ri.SessionID, - ContainerID: ri.ContainerID, - ContainerIP: ri.ContainerIP, - WorkspacePath: ri.WorkspacePath, - RuntimeName: ri.RuntimeName, - Status: v1.AgentStatusRunning, - StartedAt: time.Now(), - Metadata: ri.Metadata, - agentctl: ri.Client, - standaloneInstanceID: ri.StandaloneInstanceID, - standalonePort: ri.StandalonePort, - promptDoneCh: make(chan PromptCompletionSignal, 1), - } - m.executionStore.Add(execution) - - // Reconnect to workspace streams (shell, git, file changes) in background - // This is needed so shell.input, git status, etc. work after backend restart - go m.streamManager.ReconnectAll(execution) - } - m.logger.Info("recovered executions", zap.Int("count", len(recovered))) - } - - // Start cleanup loop when container manager is available (Docker mode) - if m.containerManager != nil { - m.wg.Add(1) - go m.cleanupLoop(ctx) - m.logger.Info("cleanup loop started") - } - - // Set up callbacks for passthrough mode (using standalone runtime) - if standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone); err == nil { - if interactiveRunner := standaloneRT.GetInteractiveRunner(); interactiveRunner != nil { - // Turn complete callback - interactiveRunner.SetTurnCompleteCallback(func(sessionID string) { - m.handlePassthroughTurnComplete(sessionID) - }) - - // Output callback for standalone passthrough (no WorkspaceTracker) - interactiveRunner.SetOutputCallback(func(output *agentctltypes.ProcessOutput) { - m.handlePassthroughOutput(output) - }) - - // Status callback for standalone passthrough (no WorkspaceTracker) - interactiveRunner.SetStatusCallback(func(status *agentctltypes.ProcessStatusUpdate) { - m.handlePassthroughStatus(status) - }) - - m.logger.Info("passthrough callbacks configured") - } - } - - return nil -} - -// GetRecoveredExecutions returns a snapshot of all currently tracked executions -// This can be used by the orchestrator to sync with the database -func (m *Manager) GetRecoveredExecutions() []RecoveredExecution { - executions := m.executionStore.List() - result := make([]RecoveredExecution, 0, len(executions)) - for _, exec := range executions { - result = append(result, RecoveredExecution{ - ExecutionID: exec.ID, - TaskID: exec.TaskID, - SessionID: exec.SessionID, - ContainerID: exec.ContainerID, - AgentProfileID: exec.AgentProfileID, - }) - } - return result -} - -// Stop stops the lifecycle manager -func (m *Manager) Stop() error { - m.logger.Info("stopping lifecycle manager") - - close(m.stopCh) - m.wg.Wait() - - return nil -} - -// StopAllAgents attempts a graceful shutdown of all active agents concurrently. -func (m *Manager) StopAllAgents(ctx context.Context) error { - executions := m.executionStore.List() - if len(executions) == 0 { - return nil - } - - var wg sync.WaitGroup - errCh := make(chan error, len(executions)) - - for _, exec := range executions { - wg.Add(1) - go func(e *AgentExecution) { - defer wg.Done() - if err := m.StopAgent(ctx, e.ID, false); err != nil { - errCh <- err - m.logger.Warn("failed to stop agent during shutdown", - zap.String("execution_id", e.ID), - zap.Error(err)) - } - }(exec) - } - - wg.Wait() - close(errCh) - - var errs []error - for err := range errCh { - errs = append(errs, err) - } - return errors.Join(errs...) -} - -// Launch launches a new agent for a task -func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) { - m.logger.Debug("launching agent", - zap.String("task_id", req.TaskID), - zap.String("agent_profile_id", req.AgentProfileID), - zap.Bool("use_worktree", req.UseWorktree)) - - // 1. Resolve the agent profile to get agent type info - var agentTypeName string - var profileInfo *AgentProfileInfo - var err error - if m.profileResolver != nil { - profileInfo, err = m.profileResolver.ResolveProfile(ctx, req.AgentProfileID) - if err != nil { - return nil, fmt.Errorf("failed to resolve agent profile: %w", err) - } - agentTypeName = profileInfo.AgentName - m.logger.Debug("resolved agent profile", - zap.String("profile_id", req.AgentProfileID), - zap.String("agent_name", profileInfo.AgentName), - zap.String("agent_type", agentTypeName)) - } else { - // Fallback: treat AgentProfileID as agent type directly (for backward compat) - agentTypeName = req.AgentProfileID - m.logger.Warn("no profile resolver configured, using profile ID as agent type", - zap.String("agent_type", agentTypeName)) - } - - // 2. Get agent config from registry - agentConfig, ok := m.registry.Get(agentTypeName) - if !ok { - return nil, fmt.Errorf("agent type %q not found in registry", agentTypeName) - } - - if !agentConfig.Enabled() { - return nil, fmt.Errorf("agent type %q is disabled", agentTypeName) - } - - // 3. Check if session already has an agent running - if req.SessionID != "" { - if existingExecution, exists := m.executionStore.GetBySessionID(req.SessionID); exists { - return nil, fmt.Errorf("session %q already has an agent running (execution: %s)", req.SessionID, existingExecution.ID) - } - } - - // 4. Handle worktree creation/reuse if enabled - workspacePath := req.WorkspacePath - var mainRepoGitDir string // Path to main repo's .git directory for container mount - var worktreeID string // Store worktree ID for session resumption - var worktreeBranch string // Store worktree branch for API responses - if req.UseWorktree && m.worktreeMgr != nil && req.RepositoryPath != "" { - wt, err := m.getOrCreateWorktree(ctx, req) - if err != nil { - m.logger.Warn("failed to create worktree, falling back to direct mount", - zap.String("task_id", req.TaskID), - zap.Error(err)) - // Fall back to direct mount if worktree creation fails - // Use RepositoryPath as the workspace - workspacePath = req.RepositoryPath - } else { - workspacePath = wt.Path - worktreeID = wt.ID - worktreeBranch = wt.Branch - // Git worktrees reference the main repo's .git directory via a .git file - // The worktree metadata in .git/worktrees/{name} contains a 'commondir' file - // that points back to the main .git directory (usually '../..') - // We need to mount the entire .git directory for git commands to work - mainRepoGitDir = filepath.Join(req.RepositoryPath, ".git") - } - } else if req.RepositoryPath != "" && workspacePath == "" { - // No worktree, but we have a repository path - use it as workspace - workspacePath = req.RepositoryPath - } - - // 5. Generate a new execution ID - executionID := uuid.New().String() - - // 6. Prepare request with worktree path - reqWithWorktree := *req - reqWithWorktree.WorkspacePath = workspacePath - - // Store task description in metadata for StartAgentProcess - if reqWithWorktree.Metadata == nil { - reqWithWorktree.Metadata = make(map[string]interface{}) - } - if req.TaskDescription != "" { - reqWithWorktree.Metadata["task_description"] = req.TaskDescription - } - if req.SessionID != "" { - reqWithWorktree.Metadata["session_id"] = req.SessionID - } - - // 6b. Add profile settings to environment - if profileInfo != nil { - if reqWithWorktree.Env == nil { - reqWithWorktree.Env = make(map[string]string) - } - if profileInfo.Model != "" { - reqWithWorktree.Env["AGENT_MODEL"] = profileInfo.Model - } - if profileInfo.AutoApprove { - reqWithWorktree.Env["AGENTCTL_AUTO_APPROVE_PERMISSIONS"] = "true" - } - } - - // 7. Launch via runtime - creates agentctl instance with workspace access only - // Agent subprocess is NOT started - call StartAgentProcess() explicitly - // Select runtime based on executor type from request - rt, err := m.getRuntimeForExecutorType(req.ExecutorType) - if err != nil { - return nil, fmt.Errorf("no runtime configured: %w", err) - } - - // Build environment variables - env := m.buildEnvForRuntime(executionID, &reqWithWorktree, agentConfig) - - // Resolve MCP servers early so they're available for protocols that need them at startup (e.g., Codex) - acpMcpServers, err := m.resolveMcpServersWithParams(ctx, reqWithWorktree.AgentProfileID, reqWithWorktree.Metadata, agentConfig) - if err != nil { - m.logger.Warn("failed to resolve MCP servers for launch", zap.Error(err)) - // Continue without MCP servers - not a fatal error - } - - // Convert ACP MCP servers to runtime config format - var mcpServers []McpServerConfig - for _, srv := range acpMcpServers { - mcpServers = append(mcpServers, McpServerConfig{ - Name: srv.Name, - URL: srv.URL, - Type: srv.Type, - Command: srv.Command, - Args: srv.Args, - }) - } - - // Build metadata with runtime-specific fields - metadata := make(map[string]interface{}) - if reqWithWorktree.Metadata != nil { - for k, v := range reqWithWorktree.Metadata { - metadata[k] = v - } - } - if mainRepoGitDir != "" { - metadata[MetadataKeyMainRepoGitDir] = mainRepoGitDir - } - if worktreeID != "" { - metadata[MetadataKeyWorktreeID] = worktreeID - } - if worktreeBranch != "" { - metadata[MetadataKeyWorktreeBranch] = worktreeBranch - } - - // Create runtime request (agent command not included - started explicitly later) - runtimeReq := &RuntimeCreateRequest{ - InstanceID: executionID, - TaskID: reqWithWorktree.TaskID, - SessionID: reqWithWorktree.SessionID, - AgentProfileID: reqWithWorktree.AgentProfileID, - WorkspacePath: reqWithWorktree.WorkspacePath, - Protocol: string(agentConfig.Runtime().Protocol), - Env: env, - Metadata: metadata, - AgentConfig: agentConfig, - McpServers: mcpServers, - } - - runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq) - if err != nil { - return nil, fmt.Errorf("failed to create execution: %w", err) - } - - // Convert to AgentExecution and set the runtime name - execution := runtimeInstance.ToAgentExecution(runtimeReq) - execution.RuntimeName = string(rt.Name()) - - // Set ACP session ID for session resumption (used by InitializeSession) - if req.ACPSessionID != "" { - execution.ACPSessionID = req.ACPSessionID - } - - // Build agent command string for later use with StartAgentProcess - model := "" - autoApprove := false - permissionValues := make(map[string]bool) - if profileInfo != nil { - model = profileInfo.Model - autoApprove = profileInfo.AutoApprove - // Build permission values map from profile info - permissionValues["auto_approve"] = profileInfo.AutoApprove - permissionValues["allow_indexing"] = profileInfo.AllowIndexing - permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions - } - // Allow model override from request (for dynamic model switching) - if req.ModelOverride != "" { - model = req.ModelOverride - } - cmdOpts := agents.CommandOptions{ - Model: model, - SessionID: req.ACPSessionID, - AutoApprove: autoApprove, - PermissionValues: permissionValues, - } - execution.AgentCommand = m.commandBuilder.BuildCommandString(agentConfig, cmdOpts) - - // 8. Track the execution - m.executionStore.Add(execution) - - // 9. Publish agent.started event - m.eventPublisher.PublishAgentEvent(ctx, events.AgentStarted, execution) - m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlStarting, execution, "") - - // 10. Wait for agentctl to be ready (for shell/workspace access) - // NOTE: This does NOT start the agent process - call StartAgentProcess() explicitly - go m.waitForAgentctlReady(execution) - - m.logger.Debug("agentctl execution created (agent not started)", - zap.String("execution_id", executionID), - zap.String("task_id", req.TaskID), - zap.String("runtime", execution.RuntimeName)) - - return execution, nil -} - -// SetExecutionDescription updates the task description stored in an execution's metadata. -// This is used when starting an agent on a workspace that was launched without a prompt. -func (m *Manager) SetExecutionDescription(_ context.Context, executionID string, description string) error { - execution, exists := m.executionStore.Get(executionID) - if !exists { - return fmt.Errorf("execution %q not found", executionID) - } - if execution.Metadata == nil { - execution.Metadata = make(map[string]interface{}) - } - execution.Metadata["task_description"] = description - return nil -} - -// StartAgentProcess configures and starts the agent subprocess for an execution. -// This must be called after Launch() to actually start the agent (e.g., auggie, codex). -// The command is built internally based on the execution's agent profile. -func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) error { - execution, exists := m.executionStore.Get(executionID) - if !exists { - return fmt.Errorf("execution %q not found", executionID) - } - - // Check if this execution should use passthrough mode - if execution.AgentProfileID != "" && m.profileResolver != nil { - profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) - if err == nil && profileInfo.CLIPassthrough { - return m.startPassthroughSession(ctx, execution, profileInfo) - } - } - - if execution.agentctl == nil { - return fmt.Errorf("execution %q has no agentctl client", executionID) - } - - if execution.AgentCommand == "" { - return fmt.Errorf("execution %q has no agent command configured", executionID) - } - - // Wait for agentctl to be ready - if err := execution.agentctl.WaitForReady(ctx, 60*time.Second); err != nil { - m.updateExecutionError(executionID, "agentctl not ready: "+err.Error()) - return fmt.Errorf("agentctl not ready: %w", err) - } - - // Get task description from metadata - taskDescription := "" - if execution.Metadata != nil { - if desc, ok := execution.Metadata["task_description"].(string); ok { - taskDescription = desc - } - } - - m.logger.Warn("StartAgentProcess: task description resolved", - zap.String("execution_id", executionID), - zap.String("task_id", execution.TaskID), - zap.Int("task_description_length", len(taskDescription)), - zap.String("agent_command", execution.AgentCommand), - zap.String("acp_session_id", execution.ACPSessionID)) - - // Build environment for the agent process - env := map[string]string{} - if taskDescription != "" { - env["TASK_DESCRIPTION"] = taskDescription - } - - // Determine approval policy and resolve agent display name from profile + registry - approvalPolicy := "" - agentDisplayName := "" - if execution.AgentProfileID != "" && m.profileResolver != nil { - if profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID); err == nil { - if profileInfo.AutoApprove { - approvalPolicy = "never" - } else { - approvalPolicy = "untrusted" - } - // Look up display name from registry (e.g. "Claude", "Auggie", "Codex") - if agentConfig, ok := m.registry.Get(profileInfo.AgentName); ok && agentConfig.DisplayName() != "" { - agentDisplayName = agentConfig.DisplayName() - } else { - agentDisplayName = profileInfo.AgentName - } - } - } - - // Configure the agent command - if err := execution.agentctl.ConfigureAgent(ctx, execution.AgentCommand, env, approvalPolicy); err != nil { - return fmt.Errorf("failed to configure agent: %w", err) - } - - // Start the agent process - fullCommand, err := execution.agentctl.Start(ctx) - if err != nil { - m.updateExecutionError(executionID, "failed to start agent: "+err.Error()) - return fmt.Errorf("failed to start agent: %w", err) - } - - // Use the full command (with adapter args) if available, otherwise fall back to the base command - bootCommand := fullCommand - if bootCommand == "" { - bootCommand = execution.AgentCommand - } - - m.logger.Info("agent process started", - zap.String("execution_id", executionID), - zap.String("task_id", execution.TaskID), - zap.String("command", bootCommand)) - - // Create boot message to show agent boot progress in chat - var bootMsg *models.Message - var bootStopCh chan struct{} - if m.bootMessageService != nil { - var bootErr error - bootMsg, bootErr = m.bootMessageService.CreateMessage(ctx, &BootMessageRequest{ - TaskSessionID: execution.SessionID, - TaskID: execution.TaskID, - Content: "", - AuthorType: "agent", - Type: "script_execution", - Metadata: map[string]interface{}{ - "script_type": "agent_boot", - "agent_name": agentDisplayName, - "command": bootCommand, - "status": "running", - "is_resuming": execution.ACPSessionID != "", - "started_at": time.Now().UTC().Format(time.RFC3339Nano), - }, - }) - if bootErr != nil { - m.logger.Warn("failed to create boot message, continuing without boot output", - zap.String("execution_id", executionID), - zap.Error(bootErr)) - } else { - // Start polling goroutine for stderr - bootStopCh = make(chan struct{}) - go m.pollAgentStderr(execution.agentctl, bootMsg, bootStopCh) - } - } - - // Give the agent process a moment to initialize - time.Sleep(500 * time.Millisecond) - - // Get agent config for ACP session initialization - agentConfig, err := m.getAgentConfigForExecution(execution) - if err != nil { - m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") - return fmt.Errorf("failed to get agent config: %w", err) - } - - mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig) - if err != nil { - m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") - m.updateExecutionError(executionID, "failed to resolve MCP config: "+err.Error()) - return fmt.Errorf("failed to resolve MCP config: %w", err) - } - - // Initialize ACP session. - // Session status events are now emitted by all adapters via the stream. - if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, mcpServers); err != nil { - m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") - m.updateExecutionError(executionID, "failed to initialize ACP: "+err.Error()) - return fmt.Errorf("failed to initialize ACP: %w", err) - } - - // Finalize boot message as successful - m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "exited") - - return nil -} - -// pollAgentStderr polls the agent's stderr buffer every 2 seconds and updates the boot message. -func (m *Manager) pollAgentStderr(client *agentctl.Client, msg *models.Message, stopCh chan struct{}) { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - var lastLineCount int - - for { - select { - case <-stopCh: - return - case <-ticker.C: - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - lines, err := client.GetAgentStderr(ctx) - cancel() - if err != nil { - m.logger.Debug("failed to poll agent stderr", zap.Error(err)) - continue - } - - if len(lines) > lastLineCount { - lastLineCount = len(lines) - msg.Content = strings.Join(lines, "\n") - ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) - if updateErr := m.bootMessageService.UpdateMessage(ctx2, msg); updateErr != nil { - m.logger.Debug("failed to update boot message with stderr", - zap.String("message_id", msg.ID), - zap.Error(updateErr)) - } - cancel2() - } - } - } -} - -// finalizeBootMessage stops the polling goroutine and updates the boot message with final status. -func (m *Manager) finalizeBootMessage(msg *models.Message, stopCh chan struct{}, client *agentctl.Client, status string) { - if msg == nil || m.bootMessageService == nil { - return - } - - // Stop the polling goroutine - if stopCh != nil { - close(stopCh) - } - - // Final stderr fetch - if client != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - lines, err := client.GetAgentStderr(ctx) - cancel() - if err == nil && len(lines) > 0 { - msg.Content = strings.Join(lines, "\n") - } - } - - msg.Metadata["status"] = status - msg.Metadata["completed_at"] = time.Now().UTC().Format(time.RFC3339Nano) - if status == "exited" { - msg.Metadata["exit_code"] = 0 - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if updateErr := m.bootMessageService.UpdateMessage(ctx, msg); updateErr != nil { - m.logger.Warn("failed to update boot message with final status", - zap.String("message_id", msg.ID), - zap.Error(updateErr)) - } -} - -// buildEnvForRuntime builds environment variables for any runtime. -// This is the unified method used by the runtime interface. -func (m *Manager) buildEnvForRuntime(executionID string, req *LaunchRequest, agentConfig agents.Agent) map[string]string { - env := make(map[string]string) - - // Copy request environment - for k, v := range req.Env { - env[k] = v - } - - // Add standard variables for recovery after backend restart - env["KANDEV_INSTANCE_ID"] = executionID - env["KANDEV_TASK_ID"] = req.TaskID - env["KANDEV_SESSION_ID"] = req.SessionID - env["KANDEV_AGENT_PROFILE_ID"] = req.AgentProfileID - env["TASK_DESCRIPTION"] = req.TaskDescription - - // Add required credentials from agent config - if m.credsMgr != nil && agentConfig != nil { - ctx := context.Background() - for _, credKey := range agentConfig.Runtime().RequiredEnv { - if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { - env[credKey] = value - } - } - } - - return env -} - -// getOrCreateWorktree creates a new worktree or reuses an existing one for session resumption. -// If worktree_id is in metadata, it tries to reuse that specific worktree. -// Otherwise, creates a new worktree with a unique random suffix. -func (m *Manager) getOrCreateWorktree(ctx context.Context, req *LaunchRequest) (*worktree.Worktree, error) { - // Check if we have a worktree_id in metadata for session resumption - var worktreeID string - if req.Metadata != nil { - if id, ok := req.Metadata["worktree_id"].(string); ok && id != "" { - worktreeID = id - } - } - - // Create request with optional WorktreeID for resumption - createReq := worktree.CreateRequest{ - TaskID: req.TaskID, - SessionID: req.SessionID, - TaskTitle: req.TaskTitle, - RepositoryID: req.RepositoryID, - RepositoryPath: req.RepositoryPath, - BaseBranch: req.BaseBranch, - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - PullBeforeWorktree: req.PullBeforeWorktree, - WorktreeID: worktreeID, // If set, will try to reuse this worktree - } - - wt, err := m.worktreeMgr.Create(ctx, createReq) - if err != nil { - return nil, fmt.Errorf("failed to create worktree: %w", err) - } - - if worktreeID != "" && wt.ID == worktreeID { - m.logger.Debug("reusing existing worktree for session resumption", - zap.String("task_id", req.TaskID), - zap.String("worktree_id", wt.ID), - zap.String("worktree_path", wt.Path), - zap.String("branch", wt.Branch)) - } else { - m.logger.Info("created new worktree for task", - zap.String("task_id", req.TaskID), - zap.String("worktree_id", wt.ID), - zap.String("worktree_path", wt.Path), - zap.String("branch", wt.Branch)) - } - - return wt, nil -} - -// waitForAgentctlReady waits for the agentctl HTTP server to be ready. -// This enables shell/workspace features without starting the agent process. -func (m *Manager) waitForAgentctlReady(execution *AgentExecution) { - opStart := time.Now() - // Use detached context that respects stopCh for graceful shutdown - ctx, cancel := appctx.Detached(context.Background(), m.stopCh, 60*time.Second) - defer cancel() - - m.logger.Debug("waiting for agentctl to be ready", - zap.String("execution_id", execution.ID), - zap.String("url", execution.agentctl.BaseURL())) - - if err := execution.agentctl.WaitForReady(ctx, 60*time.Second); err != nil { - m.logger.Error("agentctl not ready", - zap.String("execution_id", execution.ID), - zap.Duration("duration", time.Since(opStart)), - zap.Error(err)) - m.updateExecutionError(execution.ID, "agentctl not ready: "+err.Error()) - // Use the timeout context for event publishing instead of a fresh Background context - m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlError, execution, err.Error()) - return - } - - elapsed := time.Since(opStart) - if elapsed > 10*time.Second { - m.logger.Warn("agentctl ready took longer than expected", - zap.String("execution_id", execution.ID), - zap.Duration("duration", elapsed)) - } else { - m.logger.Debug("agentctl ready - shell/workspace access available", - zap.String("execution_id", execution.ID), - zap.Duration("duration", elapsed)) - } - // Use the timeout context for event publishing instead of a fresh Background context - m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "") -} - -// getAgentConfigForExecution retrieves the agent configuration for an execution. -// The execution must have AgentCommand set (which includes the agent type). -func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (agents.Agent, error) { - if execution.AgentProfileID == "" { - return nil, fmt.Errorf("execution %s has no agent profile ID", execution.ID) - } - - if m.profileResolver == nil { - return nil, fmt.Errorf("profile resolver not configured") - } - - profileInfo, err := m.profileResolver.ResolveProfile(context.Background(), execution.AgentProfileID) - if err != nil { - return nil, fmt.Errorf("failed to resolve profile: %w", err) - } - - agentTypeName := profileInfo.AgentName - agentConfig, ok := m.registry.Get(agentTypeName) - if !ok { - return nil, fmt.Errorf("agent type not found: %s", agentTypeName) - } - - return agentConfig, nil -} - -// ResolveAgentProfile resolves an agent profile ID to profile information. -// This is exposed for external callers (like the orchestrator executor) to get profile info. -// The profile's model is guaranteed to be non-empty as it's validated at creation time. -func (m *Manager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { - if m.profileResolver == nil { - return nil, fmt.Errorf("profile resolver not configured") - } - return m.profileResolver.ResolveProfile(ctx, profileID) -} - -// resolveMcpServers centralizes MCP resolution for a session: -// - loads per-agent MCP config, -// - applies executor-scoped transport rules, allow/deny lists, URL rewrites, and env injection, -// - converts to ACP stdio server definitions used during session initialization. -func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) { - if execution == nil { - return nil, nil - } - return m.resolveMcpServersWithParams(ctx, execution.AgentProfileID, execution.Metadata, agentConfig) -} - -// resolveMcpServersWithParams resolves MCP servers with explicit parameters. -// This is used by Launch() before the execution object exists. -func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID string, metadata map[string]interface{}, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) { - if m.mcpProvider == nil || agentConfig == nil { - return nil, nil - } - - profileID = strings.TrimSpace(profileID) - if profileID == "" { - return nil, nil - } - - config, err := m.mcpProvider.GetConfigByProfileID(ctx, profileID) - if err != nil { - if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) || errors.Is(err, mcpconfig.ErrAgentProfileNotFound) { - return nil, nil - } - m.logger.Warn("failed to load MCP config", - zap.String("profile_id", profileID), - zap.Error(err)) - return nil, err - } - if config == nil || !config.Enabled { - return nil, nil - } - - // Get default runtime for MCP policy (used before execution exists) - defaultRT, _ := m.getDefaultRuntime() - policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(defaultRT)) - executorID := "" - if metadata != nil { - if value, ok := metadata["executor_id"].(string); ok { - executorID = value - } - if value, ok := metadata["executor_mcp_policy"]; ok { - updated, policyWarnings, err := mcpconfig.ApplyExecutorPolicy(policy, value) - if err != nil { - return nil, fmt.Errorf("invalid executor MCP policy: %w", err) - } - policy = updated - for _, warning := range policyWarnings { - m.logger.Warn("mcp policy warning", - zap.String("profile_id", profileID), - zap.String("executor_id", executorID), - zap.String("warning", warning)) - } - } - } - resolved, warnings, err := mcpconfig.Resolve(config, policy) - if err != nil { - return nil, err - } - for _, warning := range warnings { - m.logger.Warn("mcp config warning", - zap.String("profile_id", profileID), - zap.String("executor_id", executorID), - zap.String("warning", warning)) - } - - return mcpconfig.ToACPServers(resolved), nil -} - -func runtimeName(rt Runtime) runtime.Name { - if rt == nil { - return runtime.NameUnknown - } - return rt.Name() -} - -// initializeACPSession delegates to SessionManager for full ACP session initialization and prompting -func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, mcpServers []agentctltypes.McpServer) error { - return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, mcpServers, m.MarkReady) -} - -// handleAgentEvent processes incoming agent events from the agent -func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.AgentEvent) { - // Update last activity timestamp for stall detection - execution.lastActivityAtMu.Lock() - execution.lastActivityAt = time.Now() - execution.lastActivityAtMu.Unlock() - - // Log all incoming events for debugging - m.logger.Debug("handleAgentEvent entry", - zap.String("execution_id", execution.ID), - zap.String("event_type", event.Type), - zap.String("operation_id", event.OperationID), - zap.Int("text_length", len(event.Text))) - - // Handle different event types based on the Type field - switch event.Type { - case "message_chunk": - // Accumulate message content and stream on newline boundaries for real-time feedback - if event.Text != "" { - execution.messageMu.Lock() - execution.messageBuffer.WriteString(event.Text) - bufferLenAfterWrite := execution.messageBuffer.Len() - m.logger.Debug("message_chunk written to buffer", - zap.String("execution_id", execution.ID), - zap.String("operation_id", event.OperationID), - zap.Int("text_length", len(event.Text)), - zap.Int("buffer_length_after", bufferLenAfterWrite)) - - // Check if buffer contains newlines - if so, flush up to the last newline - bufContent := execution.messageBuffer.String() - lastNewline := strings.LastIndex(bufContent, "\n") - if lastNewline != -1 { - // Extract content up to and including the last newline - toFlush := bufContent[:lastNewline+1] - remainder := bufContent[lastNewline+1:] - - // Reset buffer with remainder - execution.messageBuffer.Reset() - execution.messageBuffer.WriteString(remainder) - execution.messageMu.Unlock() - - // Publish streaming message event if there's content to flush - if strings.TrimSpace(toFlush) != "" { - m.publishStreamingMessage(execution, toFlush) - } - } else { - execution.messageMu.Unlock() - } - } - // Return early - message_chunk is transformed to message_streaming, no need to publish raw event - return - - case "reasoning": - // Stream thinking content like message chunks for real-time feedback - // All adapters normalize reasoning to ReasoningText field - if event.ReasoningText != "" { - execution.messageMu.Lock() - execution.thinkingBuffer.WriteString(event.ReasoningText) - - // Check if buffer contains newlines - if so, flush up to the last newline - bufContent := execution.thinkingBuffer.String() - lastNewline := strings.LastIndex(bufContent, "\n") - if lastNewline != -1 { - // Extract content up to and including the last newline - toFlush := bufContent[:lastNewline+1] - remainder := bufContent[lastNewline+1:] - - // Reset buffer with remainder - execution.thinkingBuffer.Reset() - execution.thinkingBuffer.WriteString(remainder) - execution.messageMu.Unlock() - - // Publish streaming thinking event if there's content to flush - if strings.TrimSpace(toFlush) != "" { - m.publishStreamingThinking(execution, toFlush) - } - } else { - execution.messageMu.Unlock() - } - } - // Return early - reasoning is transformed to thinking_streaming, no need to publish raw event - return - - case "tool_call": - // Tool call starting marks a step boundary - flush the accumulated message - // This way, each agent response before a tool use becomes a separate comment - // Include the flushed text in the event for the orchestrator to save - if flushedText := m.flushMessageBuffer(execution); flushedText != "" { - event.Text = flushedText - // Store flushed agent message to session history for context injection - if m.historyManager != nil && execution.SessionID != "" { - if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { - m.logger.Warn("failed to store agent message to history", zap.Error(err)) - } - } - } - - // Store tool call to session history for context injection - if m.historyManager != nil && execution.SessionID != "" { - if err := m.historyManager.AppendToolCall(execution.SessionID, event); err != nil { - m.logger.Warn("failed to store tool call to history", zap.Error(err)) - } - } - - m.logger.Debug("tool call started", - zap.String("execution_id", execution.ID), - zap.String("tool_call_id", event.ToolCallID), - zap.String("tool_name", event.ToolName)) - // Tool call message creation is handled by orchestrator via AgentStreamEvent - - case "tool_update": - // Store tool result to session history when complete - if m.historyManager != nil && execution.SessionID != "" && event.ToolStatus == "complete" { - if err := m.historyManager.AppendToolResult(execution.SessionID, event); err != nil { - m.logger.Warn("failed to store tool result to history", zap.Error(err)) - } - } - // Tool update handled by orchestrator via AgentStreamEvent - - case "plan": - m.logger.Debug("agent plan update", - zap.String("execution_id", execution.ID)) - - case "error": - // Flush any accumulated content and clear streaming state on error - m.flushMessageBuffer(execution) - - // Log all available error information for debugging - m.logger.Error("agent error", - zap.String("execution_id", execution.ID), - zap.String("error", event.Error), - zap.String("text", event.Text), - zap.Any("data", event.Data)) - - case "complete": - // Check buffer content BEFORE any processing - execution.messageMu.Lock() - bufferContentBeforeFlush := execution.messageBuffer.String() - currentMsgID := execution.currentMessageID - execution.messageMu.Unlock() - - // Log preview of buffer content for debugging (first 100 chars) - bufferPreview := bufferContentBeforeFlush - if len(bufferPreview) > 100 { - bufferPreview = bufferPreview[:100] + "..." - } - - // Check if this is an error completion (agent failed to process the prompt) - isError := false - if event.Data != nil { - if v, ok := event.Data["is_error"].(bool); ok { - isError = v - } - } - - m.logger.Info("agent turn complete", - zap.String("execution_id", execution.ID), - zap.String("operation_id", event.OperationID), - zap.String("session_id", event.SessionID), - zap.String("current_msg_id", currentMsgID), - zap.Int("buffer_length", len(bufferContentBeforeFlush)), - zap.String("buffer_preview", bufferPreview), - zap.Bool("is_error", isError)) - - // Flush the message buffer to publish any remaining content as a streaming message. - // If the buffer never triggered streaming (no newlines), we emit the text on the - // complete event so the orchestrator can persist the final message. - flushedText := m.flushMessageBuffer(execution) - if flushedText != "" { - event.Text = flushedText - if m.historyManager != nil && execution.SessionID != "" { - if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { - m.logger.Warn("failed to store final agent message to history", zap.Error(err)) - } - } - } - - m.logger.Info("complete event processed", - zap.String("execution_id", execution.ID), - zap.String("operation_id", event.OperationID)) - - // If this was an error completion, mark the execution as failed - // The agent process will likely exit soon after sending an error result - if isError { - errorMsg := "agent error completion" - if event.Error != "" { - errorMsg = event.Error - } - m.logger.Warn("error completion received, marking execution as failed", - zap.String("execution_id", execution.ID), - zap.String("task_id", execution.TaskID), - zap.String("error", errorMsg), - zap.String("event_error", event.Error), - zap.String("event_text", event.Text), - zap.Any("event_data", event.Data), - zap.String("agent_command", execution.AgentCommand), - zap.String("acp_session_id", execution.ACPSessionID)) - - if err := m.MarkCompleted(execution.ID, 1, errorMsg); err != nil { - m.logger.Error("failed to mark execution as failed after error completion", - zap.String("execution_id", execution.ID), - zap.Error(err)) - } - - // Remove the execution from the store so a new one can be created - // The AgentFailed event has already been published by MarkCompleted - m.RemoveExecution(execution.ID) - } else { - // Mark agent as READY for follow-up prompts - if err := m.MarkReady(execution.ID); err != nil { - m.logger.Error("failed to mark execution as ready after complete", - zap.String("execution_id", execution.ID), - zap.Error(err)) - } - } - - // Signal the completion channel so SendPrompt unblocks - stopReason := "end_turn" - errorMsg := "" - if isError { - stopReason = "error" - errorMsg = "agent error completion" - if event.Error != "" { - errorMsg = event.Error - } - } - select { - case execution.promptDoneCh <- PromptCompletionSignal{ - StopReason: stopReason, - IsError: isError, - Error: errorMsg, - }: - default: - // Channel full or no one waiting — that's fine (e.g., initial prompt in goroutine) - } - - case "permission_request": - m.logger.Debug("permission request received", - zap.String("execution_id", execution.ID), - zap.String("pending_id", event.PendingID), - zap.String("title", event.PermissionTitle)) - - // Publish permission request to dedicated subject for orchestrator to handle - m.eventPublisher.PublishPermissionRequest(execution, event) - // Return early - permission requests don't need to be published as stream events - return - - case "context_window": - m.logger.Debug("context window update received", - zap.String("execution_id", execution.ID), - zap.Int64("size", event.ContextWindowSize), - zap.Int64("used", event.ContextWindowUsed), - zap.Float64("efficiency", event.ContextEfficiency)) - - // Publish context window event to event bus for orchestrator to persist - m.eventPublisher.PublishContextWindow( - execution, - event.ContextWindowSize, - event.ContextWindowUsed, - event.ContextWindowRemaining, - event.ContextEfficiency, - ) - // Return early - context window events don't need to be published as stream events - return - - case "available_commands": - // Store available commands in the execution for later retrieval (e.g., after page refresh) - if len(event.AvailableCommands) > 0 { - execution.SetAvailableCommands(event.AvailableCommands) - m.logger.Debug("stored available commands", - zap.String("execution_id", execution.ID), - zap.String("session_id", execution.SessionID), - zap.Int("command_count", len(event.AvailableCommands))) - - // Also publish to event bus for WebSocket broadcast - m.eventPublisher.PublishAvailableCommands(execution, event.AvailableCommands) - } - // Return early - we handle broadcasting ourselves - return - } - - // Publish agent stream event to event bus for WebSocket streaming - m.eventPublisher.PublishAgentStreamEvent(execution, event) -} - -// handleGitStatusUpdate processes git status updates from the workspace tracker -func (m *Manager) handleGitStatusUpdate(execution *AgentExecution, update *agentctl.GitStatusUpdate) { - // Publish git status update to event bus for WebSocket streaming and persistence - m.eventPublisher.PublishGitStatus(execution, update) -} - -// handleGitCommitCreated processes git commit events from the workspace tracker -func (m *Manager) handleGitCommitCreated(execution *AgentExecution, commit *agentctl.GitCommitNotification) { - // Publish commit event to event bus for WebSocket streaming and orchestrator handling - m.eventPublisher.PublishGitCommit(execution, commit) -} - -// handleGitResetDetected processes git reset events from the workspace tracker -func (m *Manager) handleGitResetDetected(execution *AgentExecution, reset *agentctl.GitResetNotification) { - // Publish reset event to event bus for orchestrator handling (commit sync) - m.eventPublisher.PublishGitReset(execution, reset) -} - -// handleFileChangeNotification processes file change notifications from the workspace tracker -func (m *Manager) handleFileChangeNotification(execution *AgentExecution, notification *agentctl.FileChangeNotification) { - m.eventPublisher.PublishFileChange(execution, notification) -} - -// handleShellOutput processes shell output from the workspace stream -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) -} - -// publishStreamingMessage publishes a streaming message event for real-time text updates. -// It creates a new message on first call (currentMessageID empty) or appends to existing. -// The message ID is generated and set synchronously to avoid race conditions. -func (m *Manager) publishStreamingMessage(execution *AgentExecution, content string) { - execution.messageMu.Lock() - isAppend := execution.currentMessageID != "" - messageID := execution.currentMessageID - - // If this is the first chunk of a new message segment, generate the ID now - if !isAppend { - messageID = uuid.New().String() - execution.currentMessageID = messageID - } - execution.messageMu.Unlock() - - event := AgentStreamEventData{ - Type: "message_streaming", - Text: content, - MessageID: messageID, - IsAppend: isAppend, - } - - // Create payload manually to include streaming-specific fields - payload := &AgentStreamEventPayload{ - Type: "agent/event", - Timestamp: time.Now().UTC().Format(time.RFC3339Nano), - AgentID: execution.ID, - TaskID: execution.TaskID, - SessionID: execution.SessionID, - Data: &event, - } - - m.logger.Debug("publishing streaming message", - zap.String("execution_id", execution.ID), - zap.String("message_id", messageID), - zap.Bool("is_append", isAppend), - zap.Int("content_length", len(content))) - - // Publish the streaming event - orchestrator will handle create/append logic - m.eventPublisher.PublishAgentStreamEventPayload(payload) -} - -// flushMessageBuffer extracts any accumulated message from the buffer and returns it. -// This is called when a tool use starts or on complete to get the agent's response. -// It also clears the currentMessageID to start fresh for the next message segment. -// Additionally flushes any accumulated thinking content. -func (m *Manager) flushMessageBuffer(execution *AgentExecution) string { - execution.messageMu.Lock() - agentMessage := execution.messageBuffer.String() - thinkingContent := execution.thinkingBuffer.String() - execution.messageBuffer.Reset() - execution.thinkingBuffer.Reset() - // Clear the streaming message IDs so next segment starts fresh - currentMsgID := execution.currentMessageID - currentThinkingID := execution.currentThinkingID - execution.currentMessageID = "" - execution.currentThinkingID = "" - execution.messageMu.Unlock() - - // If we have remaining thinking content, publish it - trimmedThinking := strings.TrimSpace(thinkingContent) - if trimmedThinking != "" { - if currentThinkingID != "" { - // Append to existing streaming thinking message - m.publishStreamingThinkingFinal(execution, currentThinkingID, trimmedThinking) - } else { - // No streaming thinking message exists yet - create one with all the content - // This happens when thinking content has no newlines (never triggered streaming) - m.publishStreamingThinking(execution, trimmedThinking) - } - } - - // If we have remaining message content, publish it - trimmedMessage := strings.TrimSpace(agentMessage) - if trimmedMessage != "" { - if currentMsgID != "" { - // Publish final append to the streaming message - m.publishStreamingMessageFinal(execution, currentMsgID, trimmedMessage) - } else { - // No streaming message exists yet - create one with all the content - // This happens when message content has no newlines (never triggered streaming) - m.publishStreamingMessage(execution, trimmedMessage) - } - // Return empty since we've already handled it via streaming - return "" - } - - return "" -} - -// publishStreamingMessageFinal publishes the final chunk of a streaming message. -// This is called during flush to append any remaining buffered content. -func (m *Manager) publishStreamingMessageFinal(execution *AgentExecution, messageID, content string) { - event := AgentStreamEventData{ - Type: "message_streaming", - Text: content, - MessageID: messageID, - IsAppend: true, - } - - payload := &AgentStreamEventPayload{ - Type: "agent/event", - Timestamp: time.Now().UTC().Format(time.RFC3339Nano), - AgentID: execution.ID, - TaskID: execution.TaskID, - SessionID: execution.SessionID, - Data: &event, - } - - m.logger.Debug("publishing final streaming message chunk", - zap.String("execution_id", execution.ID), - zap.String("message_id", messageID), - zap.Int("content_length", len(content))) - - m.eventPublisher.PublishAgentStreamEventPayload(payload) -} - -// publishStreamingThinking publishes a streaming thinking event for real-time thinking updates. -// It creates a new thinking message on first call (currentThinkingID empty) or appends to existing. -// The message ID is generated and set synchronously to avoid race conditions. -func (m *Manager) publishStreamingThinking(execution *AgentExecution, content string) { - execution.messageMu.Lock() - isAppend := execution.currentThinkingID != "" - thinkingID := execution.currentThinkingID - - // If this is the first chunk of a new thinking segment, generate the ID now - if !isAppend { - thinkingID = uuid.New().String() - execution.currentThinkingID = thinkingID - } - execution.messageMu.Unlock() - - event := AgentStreamEventData{ - Type: "thinking_streaming", - Text: content, - MessageID: thinkingID, - IsAppend: isAppend, - MessageType: "thinking", - } - - // Create payload manually to include streaming-specific fields - payload := &AgentStreamEventPayload{ - Type: "agent/event", - Timestamp: time.Now().UTC().Format(time.RFC3339Nano), - AgentID: execution.ID, - TaskID: execution.TaskID, - SessionID: execution.SessionID, - Data: &event, - } - - // Publish the streaming event - orchestrator will handle create/append logic - m.eventPublisher.PublishAgentStreamEventPayload(payload) -} - -// publishStreamingThinkingFinal publishes the final chunk of a streaming thinking message. -// This is called during flush to append any remaining buffered thinking content. -func (m *Manager) publishStreamingThinkingFinal(execution *AgentExecution, thinkingID, content string) { - event := AgentStreamEventData{ - Type: "thinking_streaming", - Text: content, - MessageID: thinkingID, - IsAppend: true, - MessageType: "thinking", - } - - payload := &AgentStreamEventPayload{ - Type: "agent/event", - Timestamp: time.Now().UTC().Format(time.RFC3339Nano), - AgentID: execution.ID, - TaskID: execution.TaskID, - SessionID: execution.SessionID, - Data: &event, - } - - m.logger.Debug("publishing final streaming thinking chunk", - zap.String("execution_id", execution.ID), - zap.String("thinking_id", thinkingID), - zap.Int("content_length", len(content))) - - m.eventPublisher.PublishAgentStreamEventPayload(payload) -} - -// updateExecutionError updates an execution with an error -func (m *Manager) updateExecutionError(executionID, errorMsg string) { - m.executionStore.UpdateError(executionID, errorMsg) -} - -// PromptAgent sends a follow-up prompt to a running agent -// Attachments (images) are passed to the agent if provided -func (m *Manager) PromptAgent(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) { - execution, exists := m.executionStore.Get(executionID) - if !exists { - return nil, fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound) - } - return m.sessionManager.SendPrompt(ctx, execution, prompt, true, attachments) -} - -// CancelAgent interrupts the current agent turn without terminating the process, -// allowing the user to send a new prompt. -func (m *Manager) CancelAgent(ctx context.Context, executionID string) error { - execution, exists := m.executionStore.Get(executionID) - if !exists { - return fmt.Errorf("execution %q not found", executionID) - } - - if execution.agentctl == nil { - return fmt.Errorf("execution %q has no agentctl client", executionID) - } - - m.logger.Info("cancelling agent turn", - zap.String("execution_id", executionID), - zap.String("task_id", execution.TaskID), - zap.String("session_id", execution.SessionID)) - - if err := execution.agentctl.Cancel(ctx); err != nil { - m.logger.Error("failed to cancel agent turn", - zap.String("execution_id", executionID), - zap.Error(err)) - return fmt.Errorf("failed to cancel agent: %w", err) - } - - // Clear streaming state after cancel to ensure clean state for next prompt - execution.messageMu.Lock() - execution.messageBuffer.Reset() - execution.thinkingBuffer.Reset() - execution.currentMessageID = "" - execution.currentThinkingID = "" - execution.messageMu.Unlock() - - // Mark as ready for follow-up prompts after successful cancel - if err := m.MarkReady(executionID); err != nil { - m.logger.Warn("failed to mark execution as ready after cancel", - zap.String("execution_id", executionID), - zap.Error(err)) - } - - m.logger.Info("agent turn cancelled successfully", - zap.String("execution_id", executionID)) - - return nil -} - -// CancelAgentBySessionID cancels the current agent turn for a specific session -func (m *Manager) CancelAgentBySessionID(ctx context.Context, sessionID string) error { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return fmt.Errorf("no agent running for session %q", sessionID) - } - - return m.CancelAgent(ctx, execution.ID) -} - -// StopAgent stops an agent execution -func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool) error { - execution, exists := m.executionStore.Get(executionID) - if !exists { - return fmt.Errorf("execution %q not found", executionID) - } - - m.logger.Info("stopping agent", - zap.String("execution_id", executionID), - zap.Bool("force", force), - zap.String("runtime", execution.RuntimeName)) - - // Try to gracefully stop via agentctl first - if execution.agentctl != nil && !force { - if err := execution.agentctl.Stop(ctx); err != nil { - m.logger.Warn("failed to stop agent via agentctl", - zap.String("execution_id", executionID), - zap.Error(err)) - } - execution.agentctl.Close() - } - - // Stop the agent execution via the runtime that created it - if execution.RuntimeName != "" && m.runtimeRegistry != nil { - rt, err := m.runtimeRegistry.GetRuntime(runtime.Name(execution.RuntimeName)) - if err != nil { - m.logger.Warn("failed to get runtime for stopping execution", - zap.String("execution_id", executionID), - zap.String("runtime", execution.RuntimeName), - zap.Error(err)) - } else { - // Stop passthrough process if in passthrough mode - if execution.PassthroughProcessID != "" { - if interactiveRunner := rt.GetInteractiveRunner(); interactiveRunner != nil { - if err := interactiveRunner.Stop(ctx, execution.PassthroughProcessID); err != nil { - m.logger.Warn("failed to stop passthrough process", - zap.String("execution_id", executionID), - zap.String("process_id", execution.PassthroughProcessID), - zap.Error(err)) - } else { - m.logger.Info("passthrough process stopped", - zap.String("execution_id", executionID), - zap.String("process_id", execution.PassthroughProcessID)) - } - } - } - - runtimeInstance := &RuntimeInstance{ - InstanceID: execution.ID, - TaskID: execution.TaskID, - ContainerID: execution.ContainerID, - StandaloneInstanceID: execution.standaloneInstanceID, - StandalonePort: execution.standalonePort, - } - if err := rt.StopInstance(ctx, runtimeInstance, force); err != nil { - // Log the error but don't return - we still need to clean up the execution store. - // This ensures that even if the runtime is unavailable (e.g., process crashed), - // the execution is removed from tracking so new executions can be launched. - m.logger.Warn("failed to stop runtime instance, continuing with cleanup", - zap.String("execution_id", executionID), - zap.Error(err)) - } - } - } - - // Update execution status and remove from tracking - _ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) { - exec.Status = v1.AgentStatusStopped - now := time.Now() - exec.FinishedAt = &now - }) - m.executionStore.Remove(executionID) - - m.logger.Info("agent stopped and removed from tracking", - zap.String("execution_id", executionID), - zap.String("task_id", execution.TaskID)) - - // Publish stopped event - m.eventPublisher.PublishAgentEvent(ctx, events.AgentStopped, execution) - - return nil -} - -// StopBySessionID stops the agent for a specific session -func (m *Manager) StopBySessionID(ctx context.Context, sessionID string, force bool) error { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return fmt.Errorf("no agent running for session %q", sessionID) - } - - return m.StopAgent(ctx, execution.ID, force) -} - -// GetExecution returns an agent execution by ID. -// -// Returns (execution, true) if found, or (nil, false) if not found. -// The returned execution pointer should not be modified directly - use the Manager's -// methods to update execution state (MarkReady, MarkCompleted, UpdateStatus). -// -// Thread-safe: Can be called concurrently from multiple goroutines. -func (m *Manager) GetExecution(executionID string) (*AgentExecution, bool) { - return m.executionStore.Get(executionID) -} - -// GetExecutionBySessionID returns the agent execution for a session. -// -// Returns (execution, true) if found, or (nil, false) if not found. -// A session can have at most one active execution at a time. If a session exists -// but has no active execution, this returns (nil, false). -// -// Thread-safe: Can be called concurrently from multiple goroutines. -func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bool) { - return m.executionStore.GetBySessionID(sessionID) -} - -// GetAvailableCommandsForSession returns the available slash commands for a session. -// Returns nil if the session doesn't exist or has no commands stored. -// -// Thread-safe: Can be called concurrently from multiple goroutines. -func (m *Manager) GetAvailableCommandsForSession(sessionID string) []streams.AvailableCommand { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return nil - } - return execution.GetAvailableCommands() -} - -// ListExecutions returns all currently tracked agent executions. -// -// Returns a snapshot of all executions in memory at the time of call. The returned slice -// contains pointers to execution objects that may be modified by other goroutines after -// this method returns. Do not modify execution state directly - use Manager methods instead. -// -// The list includes executions in all states: -// - Starting (process launching, agentctl initializing) -// - Running (actively processing prompts) -// - Ready (waiting for user input) -// - Completed/Failed (finished but not yet removed) -// -// Thread-safe: Can be called concurrently. Returns a new slice on each call. -// -// Typical usage: Status endpoints, debugging, cleanup loops. -func (m *Manager) ListExecutions() []*AgentExecution { - return m.executionStore.List() -} - -// IsAgentRunningForSession checks if an agent process is running or starting for a session. -// -// This probes agentctl's status endpoint to verify the agent process state. Returns true if: -// - Agent status is "running" (actively processing prompts) -// - Agent status is "starting" (process launched but not yet ready) -// -// Returns false if: -// - No execution exists for this session -// - agentctl client is not available -// - Status check fails (network/timeout error) -// - Agent is in any other state (stopped, failed, etc.) -// -// Note: The name "IsAgentRunning" is slightly misleading - it includes "starting" state. -// Use this to check if an agent subprocess exists for the session, regardless of ready state. -func (m *Manager) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { - // First check if we have an execution tracked for this session - execution, exists := m.GetExecutionBySessionID(sessionID) - if !exists { - return false - } - - // Probe agentctl status to verify the agent process is running - if execution.agentctl == nil { - return false - } - - status, err := execution.agentctl.GetStatus(ctx) - if err != nil { - m.logger.Debug("failed to get agentctl status", - zap.String("session_id", sessionID), - zap.Error(err)) - return false - } - - return status.IsAgentRunning() -} - -// UpdateStatus updates the status of an execution -func (m *Manager) UpdateStatus(executionID string, status v1.AgentStatus) error { - if err := m.executionStore.WithLock(executionID, func(execution *AgentExecution) { - execution.Status = status - }); err != nil { - if errors.Is(err, ErrExecutionNotFound) { - return fmt.Errorf("execution %q not found", executionID) - } - return err - } - - m.logger.Debug("updated execution status", - zap.String("execution_id", executionID), - zap.String("status", string(status))) - - return nil -} - -// MarkReady marks an execution as ready for follow-up prompts. -// -// This transitions the execution to the "ready" state, indicating the agent has finished -// processing the current prompt and is waiting for user input. This is called: -// - After agent initialization completes (session loaded, workspace ready) -// - After agent finishes processing a prompt (via stream completion event) -// - After cancelling an agent turn (to allow new prompts) -// -// State Machine Transitions: -// -// Starting -> Ready (after initialization) -// Running -> Ready (after prompt completion) -// Any -> Ready (after cancel) -// -// Publishes an AgentReady event to notify subscribers (frontend, orchestrator). -// -// Returns error if execution not found. -func (m *Manager) MarkReady(executionID string) error { - execution, exists := m.executionStore.Get(executionID) - if !exists { - return fmt.Errorf("execution %q not found", executionID) - } - - // Skip if already ready (prevents duplicate events) - if execution.Status == v1.AgentStatusReady { - return nil - } - - m.executionStore.UpdateStatus(executionID, v1.AgentStatusReady) - - m.logger.Info("execution ready for follow-up prompts", - zap.String("execution_id", executionID)) - - // Publish ready event - m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentReady, execution) - - return nil -} - -// MarkCompleted marks an execution as completed or failed. -// -// This is called when the agent process terminates, either successfully or with an error. -// The final status is determined by exit code and error message: -// -// - exitCode == 0 && errorMessage == "" → AgentStatusCompleted (success) -// - Otherwise → AgentStatusFailed (failure) -// -// Parameters: -// - executionID: The execution to mark as completed -// - exitCode: Process exit code (0 = success, non-zero = failure) -// - errorMessage: Human-readable error description (empty string if no error) -// -// State Machine: -// -// This is a terminal state transition - no further state changes are expected after this. -// Typical flow: Starting -> Running -> Ready -> ... -> Completed/Failed -// -// Publishes either AgentCompleted or AgentFailed event depending on final status. -// -// Returns error if execution not found. -func (m *Manager) MarkCompleted(executionID string, exitCode int, errorMessage string) error { - execution, exists := m.executionStore.Get(executionID) - if !exists { - return fmt.Errorf("execution %q not found", executionID) - } - - _ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) { - now := time.Now() - exec.FinishedAt = &now - exec.ExitCode = &exitCode - exec.ErrorMessage = errorMessage - - if exitCode == 0 && errorMessage == "" { - exec.Status = v1.AgentStatusCompleted - } else { - exec.Status = v1.AgentStatusFailed - } - }) - - m.logger.Info("execution completed", - zap.String("execution_id", executionID), - zap.Int("exit_code", exitCode), - zap.String("status", string(execution.Status))) - - // Publish completion event - eventType := events.AgentCompleted - if execution.Status == v1.AgentStatusFailed { - eventType = events.AgentFailed - } - m.eventPublisher.PublishAgentEvent(context.Background(), eventType, execution) - - return nil -} - -// RemoveExecution removes an execution from tracking. -// -// ⚠️ WARNING: This is a potentially dangerous operation that should only be called when: -// 1. The agent process has been fully stopped (via StopAgent) -// 2. All cleanup operations have completed (worktree cleanup, container removal) -// 3. The execution is in a terminal state (Completed, Failed, or Cancelled) -// -// This method: -// - Removes the execution from the in-memory store -// - Makes the sessionID available for new executions -// - Does NOT stop the agent process (call StopAgent first) -// - Does NOT close the agentctl client (call execution.agentctl.Close() first) -// - Does NOT clean up resources (worktrees, containers, etc.) -// -// After calling this, the executionID and sessionID can no longer be used to query -// or control the execution. Any references to this execution will become invalid. -// -// Typical usage: Called by cleanup loops or after successful StopAgent completion. -// For stale/dead executions, use CleanupStaleExecutionBySessionID instead. -func (m *Manager) RemoveExecution(executionID string) { - m.executionStore.Remove(executionID) - m.logger.Debug("removed execution from tracking", - zap.String("execution_id", executionID)) -} - -// CleanupStaleExecutionBySessionID removes a stale execution from tracking without stopping it. -// -// A "stale" execution is one where the agent process has stopped externally (crashed, killed, -// or terminated outside of our control) but the execution is still tracked in memory. -// -// When to use this: -// - After detecting the agentctl HTTP server is unreachable -// - When the agent container no longer exists (Docker runtime) -// - After server restart when recovering persisted state -// - When IsAgentRunningForSession returns false but execution exists -// -// This method performs cleanup: -// 1. Closes the agentctl HTTP client connection -// 2. Removes the execution from the in-memory tracking store -// -// What this does NOT do: -// - Stop the agent process (assumed already stopped) -// - Clean up worktrees or containers (caller's responsibility) -// - Update database session state (caller's responsibility) -// -// This is safe to call even if the process is still running - it won't send kill signals. -// Use StopAgent if you need to actively terminate a running agent. -// -// Returns nil if no execution exists for the session (idempotent). -func (m *Manager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return nil // No execution to clean up - } - - m.logger.Info("cleaning up stale agent execution", - zap.String("session_id", sessionID), - zap.String("execution_id", execution.ID)) - - // Close agentctl connection if it exists - if execution.agentctl != nil { - execution.agentctl.Close() - } - - // Remove from execution store - m.executionStore.Remove(execution.ID) - - return nil -} - -// cleanupLoop runs periodic cleanup of stale containers -func (m *Manager) cleanupLoop(ctx context.Context) { - defer m.wg.Done() - - ticker := time.NewTicker(m.cleanupInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - m.logger.Info("cleanup loop stopped (context cancelled)") - return - case <-m.stopCh: - m.logger.Info("cleanup loop stopped") - return - case <-ticker.C: - m.performCleanup(ctx) - } - } -} - -// performCleanup checks for and cleans up stale containers (Docker mode only) -func (m *Manager) performCleanup(ctx context.Context) { - m.logger.Debug("running cleanup check") - - // Skip cleanup if container manager is not available - if m.containerManager == nil { - m.logger.Debug("skipping cleanup - no container manager") - return - } - - // List all kandev-managed containers - containers, err := m.containerManager.ListManagedContainers(ctx) - if err != nil { - m.logger.Error("failed to list containers for cleanup", zap.Error(err)) - return - } - - for _, container := range containers { - // Check if container is exited and we're tracking it - if container.State == "exited" { - execution, tracked := m.executionStore.GetByContainerID(container.ID) - if tracked { - // Get container info to get exit code - info, err := m.containerManager.GetContainerInfo(ctx, container.ID) - if err != nil { - m.logger.Warn("failed to get container info during cleanup", - zap.String("container_id", container.ID), - zap.Error(err)) - continue - } - - // Mark execution as completed - errorMsg := "" - if info.ExitCode != 0 { - errorMsg = fmt.Sprintf("container exited with code %d", info.ExitCode) - } - _ = m.MarkCompleted(execution.ID, info.ExitCode, errorMsg) - - // Remove the container - if err := m.containerManager.RemoveContainer(ctx, container.ID, false); err != nil { - m.logger.Warn("failed to remove container during cleanup", - zap.String("container_id", container.ID), - zap.Error(err)) - } - - // Remove the execution from tracking so new agents can be launched - m.RemoveExecution(execution.ID) - } - } - } -} - -// RespondToPermission sends a response to an agent's permission request. -// -// When an agent requests permission (e.g., to run a bash command, modify files, etc.), -// it pauses execution and waits for user approval. This method sends the user's response. -// -// Parameters: -// - executionID: The agent execution waiting for permission -// - pendingID: Unique ID of the permission request (from permission request event) -// - optionID: The user-selected option ID (from the permission request's options array) -// - cancelled: If true, indicates user cancelled/rejected the permission request. -// When cancelled=true, optionID is ignored. -// -// Response Semantics: -// - cancelled=false, optionID="approve" → User approved the action -// - cancelled=false, optionID="deny" → User explicitly denied the action -// - cancelled=true, optionID="" → User cancelled/closed the dialog -// -// After receiving the response, the agent will either: -// - Continue executing (if approved) -// - Skip the action and report failure (if denied/cancelled) -// -// Timeout: 30 seconds for agentctl to acknowledge the response. -// -// Returns error if execution not found, agentctl unavailable, or communication fails. -func (m *Manager) RespondToPermission(executionID, pendingID, optionID string, cancelled bool) error { - execution, exists := m.executionStore.Get(executionID) - if !exists { - return fmt.Errorf("agent execution not found: %s", executionID) - } - - if execution.agentctl == nil { - return fmt.Errorf("agent execution has no agentctl client: %s", executionID) - } - - m.logger.Info("responding to permission request", - zap.String("execution_id", executionID), - zap.String("pending_id", pendingID), - zap.String("option_id", optionID), - zap.Bool("cancelled", cancelled)) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - return execution.agentctl.RespondToPermission(ctx, pendingID, optionID, cancelled) -} - -// RespondToPermissionBySessionID sends a response to a permission request using session ID. -// -// Convenience method that looks up the execution by session ID and delegates to RespondToPermission. -// See RespondToPermission for parameter semantics and behavior. -func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID string, cancelled bool) error { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return fmt.Errorf("no agent execution found for session: %s", sessionID) - } - - return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled) -} - -// MarkPassthroughRunning marks a passthrough execution as running when user submits input. -// This is called when Enter key is detected in the terminal handler. -// It updates the execution status and publishes an AgentRunning event. -func (m *Manager) MarkPassthroughRunning(sessionID string) error { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return fmt.Errorf("no agent execution found for session: %s", sessionID) - } - - if execution.PassthroughProcessID == "" { - return fmt.Errorf("session %s is not in passthrough mode", sessionID) - } - - // Only publish if not already running (prevents duplicate events) - if execution.Status != v1.AgentStatusRunning { - m.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning) - m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentRunning, execution) - } - - return nil -} - -// WritePassthroughStdin writes data to the agent process stdin in passthrough mode. -// Returns an error if the session is not in passthrough mode or if writing fails. -// Note: For terminal handler input, use MarkPassthroughRunning directly since -// the terminal handler writes to PTY directly for performance. -func (m *Manager) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return fmt.Errorf("no agent execution found for session: %s", sessionID) - } - - if execution.PassthroughProcessID == "" { - return fmt.Errorf("session %s is not in passthrough mode", sessionID) - } - - // Get the interactive runner from runtime - interactiveRunner := m.GetInteractiveRunner() - if interactiveRunner == nil { - return fmt.Errorf("interactive runner not available") - } - - // Write to stdin - if err := interactiveRunner.WriteStdin(execution.PassthroughProcessID, data); err != nil { - return err - } - - return nil -} - -// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. -func (m *Manager) IsPassthroughSession(ctx context.Context, sessionID string) bool { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return false - } - return execution.PassthroughProcessID != "" -} - -// ResizePassthroughPTY resizes the PTY for a passthrough process. -// Returns an error if the session is not in passthrough mode or if resizing fails. -func (m *Manager) ResizePassthroughPTY(ctx context.Context, sessionID string, cols, rows uint16) error { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return fmt.Errorf("no agent execution found for session: %s", sessionID) - } - - if execution.PassthroughProcessID == "" { - return fmt.Errorf("session %s is not in passthrough mode", sessionID) - } - - // Get the interactive runner from runtime - interactiveRunner := m.GetInteractiveRunner() - if interactiveRunner == nil { - return fmt.Errorf("interactive runner not available") - } - - return interactiveRunner.ResizeBySession(sessionID, cols, rows) -} - -// GetPassthroughBuffer returns the buffered output from the passthrough process. -// This is used for new subscribers to catch up on output. -func (m *Manager) GetPassthroughBuffer(ctx context.Context, sessionID string) (string, error) { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return "", fmt.Errorf("no agent execution found for session: %s", sessionID) - } - - if execution.PassthroughProcessID == "" { - return "", fmt.Errorf("session %s is not in passthrough mode", sessionID) - } - - // Get the interactive runner from runtime - interactiveRunner := m.GetInteractiveRunner() - if interactiveRunner == nil { - return "", fmt.Errorf("interactive runner not available") - } - - chunks, ok := interactiveRunner.GetBuffer(execution.PassthroughProcessID) - if !ok { - return "", fmt.Errorf("passthrough process not found") - } - - // Concatenate all chunks into a single string - var buffer strings.Builder - for _, chunk := range chunks { - buffer.WriteString(chunk.Data) - } - - return buffer.String(), nil -} - -// startPassthroughSession starts an agent in passthrough mode (direct terminal interaction). -// Instead of using ACP protocol, the agent's stdin/stdout is passed through directly. -func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentExecution, profileInfo *AgentProfileInfo) error { - // Get agent config for passthrough command - agentConfig, err := m.getAgentConfigForExecution(execution) - if err != nil { - return fmt.Errorf("failed to get agent config: %w", err) - } - - // Validate passthrough support - ptAgent, ok := agentConfig.(agents.PassthroughAgent) - if !ok { - return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) - } - - pt := ptAgent.PassthroughConfig() - rt := agentConfig.Runtime() - - // Get task description from metadata for initial prompt - taskDescription := "" - if execution.Metadata != nil { - if desc, ok := execution.Metadata["task_description"].(string); ok { - taskDescription = desc - } - } - - // Build passthrough command with initial prompt and profile settings - cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ - Model: profileModel(profileInfo), - SessionID: execution.ACPSessionID, - Prompt: taskDescription, - PermissionValues: profilePermissionValues(profileInfo), - }) - if cmd.IsEmpty() { - return fmt.Errorf("passthrough command is empty for agent %s", agentConfig.ID()) - } - - m.logger.Info("passthrough command built", - zap.String("session_id", execution.SessionID), - zap.Strings("full_command", cmd.Args())) - - // Get the interactive runner from runtime - interactiveRunner := m.GetInteractiveRunner() - if interactiveRunner == nil { - return fmt.Errorf("interactive runner not available for passthrough mode") - } - - // Build environment variables - env := make(map[string]string) - env["KANDEV_TASK_ID"] = execution.TaskID - env["KANDEV_SESSION_ID"] = execution.SessionID - env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID - - // Add required credentials from agent config - if m.credsMgr != nil { - for _, credKey := range rt.RequiredEnv { - if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { - env[credKey] = value - } - } - } - - // Start the interactive process immediately with default dimensions - // Start process - either immediately (default) or wait for terminal connection - // Some agents (like Codex) require the terminal to be connected first because - // they query the terminal for cursor position on startup - startReq := process.InteractiveStartRequest{ - SessionID: execution.SessionID, - Command: cmd.Args(), - WorkingDir: execution.WorkspacePath, - Env: env, - PromptPattern: pt.PromptPattern, - IdleTimeout: pt.IdleTimeout, - BufferMaxBytes: pt.BufferMaxBytes, - StatusDetector: pt.StatusDetector, - CheckInterval: pt.CheckInterval, - StabilityWindow: pt.StabilityWindow, - ImmediateStart: !pt.WaitForTerminal, - DefaultCols: 120, - DefaultRows: 40, - } - - processInfo, err := interactiveRunner.Start(ctx, startReq) - if err != nil { - m.updateExecutionError(execution.ID, "failed to start passthrough session: "+err.Error()) - return fmt.Errorf("failed to start passthrough session: %w", err) - } - - // Store the passthrough process ID - execution.PassthroughProcessID = processInfo.ID - - m.logger.Info("passthrough session started", - zap.String("execution_id", execution.ID), - zap.String("task_id", execution.TaskID), - zap.String("session_id", execution.SessionID), - zap.String("process_id", processInfo.ID), - zap.Strings("command", cmd.Args())) - - // Emit agentctl ready event to indicate session is available - m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "") - - // Start shell session for workspace shell access. - // In passthrough mode, the agent runs via InteractiveRunner, so the agentctl - // process manager never starts and the shell isn't auto-created. - // We need to start it explicitly so the right panel terminal works. - if execution.agentctl != nil { - if err := execution.agentctl.StartShell(ctx); err != nil { - m.logger.Warn("failed to start shell for passthrough session", - zap.String("execution_id", execution.ID), - zap.Error(err)) - // Non-fatal: continue without shell - } else { - m.logger.Info("shell session started for passthrough mode", - zap.String("execution_id", execution.ID)) - } - } - - // Connect to workspace stream for shell/git/file features even in passthrough mode. - // The passthrough terminal handles agent interaction (center panel), while the - // workspace stream provides shell I/O (right panel) - they work independently. - if m.streamManager != nil && execution.agentctl != nil { - go m.streamManager.connectWorkspaceStream(execution, nil) - } - - return nil -} - -// profileModel extracts the model from profile info, returning empty string if nil. -func profileModel(p *AgentProfileInfo) string { - if p == nil { - return "" - } - return p.Model -} - -// profilePermissionValues builds a permission values map from profile info. -func profilePermissionValues(p *AgentProfileInfo) map[string]bool { - if p == nil { - return nil - } - return map[string]bool{ - "auto_approve": p.AutoApprove, - "dangerously_skip_permissions": p.DangerouslySkipPermissions, - "allow_indexing": p.AllowIndexing, - } -} - -// ResumePassthroughSession restarts a passthrough session after backend restart. -// This is called when user reconnects to a terminal but the PTY process is no longer running. -// If the agent supports resume, it uses the resume flag to continue the last conversation. -// Otherwise, it starts a fresh CLI session with the same profile settings. -func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string) error { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - return fmt.Errorf("no execution found for session: %s", sessionID) - } - - // Get agent config - agentConfig, err := m.getAgentConfigForExecution(execution) - if err != nil { - return fmt.Errorf("failed to get agent config: %w", err) - } - - ptAgent, ok := agentConfig.(agents.PassthroughAgent) - if !ok { - return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) - } - - pt := ptAgent.PassthroughConfig() - rt := agentConfig.Runtime() - - // Get profile info for permission settings - var profileInfo *AgentProfileInfo - if m.profileResolver != nil && execution.AgentProfileID != "" { - profileInfo, _ = m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) - } - - // Build the resume command - cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ - Model: profileModel(profileInfo), - Resume: true, - PermissionValues: profilePermissionValues(profileInfo), - }) - if cmd.IsEmpty() { - return fmt.Errorf("passthrough resume command is empty for agent %s", agentConfig.ID()) - } - - m.logger.Info("resuming passthrough session", - zap.String("session_id", sessionID), - zap.String("execution_id", execution.ID), - zap.Strings("command", cmd.Args())) - - // Get the interactive runner - interactiveRunner := m.GetInteractiveRunner() - if interactiveRunner == nil { - return fmt.Errorf("interactive runner not available") - } - - // Build environment variables - env := make(map[string]string) - env["KANDEV_TASK_ID"] = execution.TaskID - env["KANDEV_SESSION_ID"] = execution.SessionID - env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID - - // Add required credentials - if m.credsMgr != nil { - for _, credKey := range rt.RequiredEnv { - if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { - env[credKey] = value - } - } - } - - // Start the interactive process. - // Always use immediate start on resume — the terminal WebSocket is already connected, - // so we don't need to wait for a resize to get exact dimensions. The first resize - // from the terminal will correct the dimensions. Without this, TUI apps that use - // WaitForTerminal would never start because the frontend may not send resizes - // to a process it doesn't know about yet. - startReq := process.InteractiveStartRequest{ - SessionID: sessionID, - Command: cmd.Args(), - WorkingDir: execution.WorkspacePath, - Env: env, - IdleTimeout: pt.IdleTimeout, - BufferMaxBytes: pt.BufferMaxBytes, - StatusDetector: pt.StatusDetector, - CheckInterval: pt.CheckInterval, - StabilityWindow: pt.StabilityWindow, - ImmediateStart: true, - DefaultCols: 120, - DefaultRows: 40, - } - - processInfo, err := interactiveRunner.Start(ctx, startReq) - if err != nil { - return fmt.Errorf("failed to start passthrough session: %w", err) - } - - // Update the execution with new process ID - execution.PassthroughProcessID = processInfo.ID - - m.logger.Info("passthrough session resumed", - zap.String("session_id", sessionID), - zap.String("execution_id", execution.ID), - zap.String("process_id", processInfo.ID)) - - // Start shell session for workspace shell access (right panel terminal). - // This needs to be done after resume since the shell process was killed on backend restart. - if execution.agentctl != nil { - if err := execution.agentctl.StartShell(ctx); err != nil { - m.logger.Warn("failed to start shell for resumed passthrough session", - zap.String("execution_id", execution.ID), - zap.Error(err)) - // Non-fatal: continue without shell - } else { - m.logger.Info("shell session started for resumed passthrough session", - zap.String("execution_id", execution.ID)) - } - } - - // Connect to workspace stream for shell/git/file features. - // Only connect if not already connected (process restart reuses the same agentctl). - if m.streamManager != nil && execution.agentctl != nil && execution.GetWorkspaceStream() == nil { - go m.streamManager.connectWorkspaceStream(execution, nil) - } - - return nil -} - -// handlePassthroughTurnComplete is called when turn detection fires for a passthrough session. -// This marks the execution as ready for follow-up prompts when the agent finishes processing. -func (m *Manager) handlePassthroughTurnComplete(sessionID string) { - execution, exists := m.executionStore.GetBySessionID(sessionID) - if !exists { - m.logger.Debug("turn complete for unknown session (may have ended)", - zap.String("session_id", sessionID)) - return - } - - m.logger.Info("passthrough turn complete", - zap.String("session_id", sessionID), - zap.String("execution_id", execution.ID)) - - // Mark execution as ready for follow-up prompts - // This publishes AgentReady event to notify subscribers - if err := m.MarkReady(execution.ID); err != nil { - m.logger.Error("failed to mark execution as ready after passthrough turn complete", - zap.String("execution_id", execution.ID), - zap.Error(err)) - } -} - -// handlePassthroughOutput handles output from a passthrough process and publishes it to the event bus. -// This is called when running in standalone mode without a WorkspaceTracker. -func (m *Manager) handlePassthroughOutput(output *agentctltypes.ProcessOutput) { - if output == nil { - return - } - - execution, exists := m.executionStore.GetBySessionID(output.SessionID) - if !exists { - m.logger.Debug("passthrough output for unknown session", - zap.String("session_id", output.SessionID)) - return - } - - // Convert to agentctl client type for event publisher - clientOutput := &agentctl.ProcessOutput{ - SessionID: output.SessionID, - ProcessID: output.ProcessID, - Kind: streams.ProcessKind(output.Kind), - Stream: output.Stream, - Data: output.Data, - Timestamp: output.Timestamp, - } - - m.eventPublisher.PublishProcessOutput(execution, clientOutput) -} - -// handlePassthroughStatus handles status updates from a passthrough process and publishes to the event bus. -// This is called when running in standalone mode without a WorkspaceTracker. -// When the process exits while a WebSocket is connected, it attempts auto-restart with rate limiting. -func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpdate) { - if status == nil { - return - } - - execution, exists := m.executionStore.GetBySessionID(status.SessionID) - if !exists { - m.logger.Debug("passthrough status for unknown session", - zap.String("session_id", status.SessionID)) - return - } - - // Convert to agentctl client type for event publisher - clientStatus := &agentctl.ProcessStatusUpdate{ - SessionID: status.SessionID, - ProcessID: status.ProcessID, - Kind: streams.ProcessKind(status.Kind), - Command: status.Command, - ScriptName: status.ScriptName, - WorkingDir: status.WorkingDir, - Status: streams.ProcessStatus(status.Status), - ExitCode: status.ExitCode, - Timestamp: status.Timestamp, - } - - m.eventPublisher.PublishProcessStatus(execution, clientStatus) - - // Check if process exited and should be auto-restarted - // Only restart if this is the ACTUAL passthrough process, not user shell terminals - // Run asynchronously to allow the old process to be cleaned up first - if status.Status == agentctltypes.ProcessStatusExited || status.Status == agentctltypes.ProcessStatusFailed { - // Only trigger auto-restart for the passthrough process, not for user shell terminals - if execution.PassthroughProcessID != "" && status.ProcessID == execution.PassthroughProcessID { - go m.handlePassthroughExit(execution, status) - } else { - m.logger.Debug("process exited but not the passthrough process, skipping auto-restart", - zap.String("session_id", status.SessionID), - zap.String("exited_process_id", status.ProcessID), - zap.String("passthrough_process_id", execution.PassthroughProcessID)) - } - } -} - -// handlePassthroughExit handles auto-restart logic when a passthrough process exits. -// This function is called asynchronously to allow the old process to be cleaned up first. -func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agentctltypes.ProcessStatusUpdate) { - const restartDelay = 500 * time.Millisecond - const cleanupDelay = 100 * time.Millisecond // Wait for old process cleanup - - // Wait a bit for the old process to be cleaned up from the process map - time.Sleep(cleanupDelay) - - sessionID := execution.SessionID - - interactiveRunner := m.GetInteractiveRunner() - if interactiveRunner == nil { - m.logger.Debug("no interactive runner available for auto-restart", - zap.String("session_id", sessionID)) - return - } - - // Check if WebSocket is still connected (use session-level tracking which survives process deletion) - if !interactiveRunner.HasActiveWebSocketBySession(sessionID) { - m.logger.Debug("no active WebSocket, skipping auto-restart", - zap.String("session_id", sessionID)) - return - } - - exitCode := 0 - if status.ExitCode != nil { - exitCode = *status.ExitCode - } - - m.logger.Info("passthrough process exited with active WebSocket, attempting auto-restart", - zap.String("session_id", sessionID), - zap.Int("exit_code", exitCode)) - - // Send restart notification to terminal (use session-level to survive process deletion) - restartMsg := "\r\n\x1b[33m[Agent exited. Restarting...]\x1b[0m\r\n" - if err := interactiveRunner.WriteToDirectOutputBySession(sessionID, []byte(restartMsg)); err != nil { - m.logger.Debug("failed to write restart message to terminal", - zap.String("session_id", sessionID), - zap.Error(err)) - } - - // Delay before restart - time.Sleep(restartDelay) - - // Check WebSocket is still connected after delay (use session-level tracking) - if !interactiveRunner.HasActiveWebSocketBySession(sessionID) { - m.logger.Debug("WebSocket disconnected during restart delay, aborting", - zap.String("session_id", sessionID)) - return - } - - // Attempt restart - ctx := context.Background() - if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { - m.logger.Error("failed to auto-restart passthrough session", - zap.String("session_id", sessionID), - zap.Error(err)) - - // Send error message to terminal - errorMsg := fmt.Sprintf("\r\n\x1b[31m[Restart failed: %s]\x1b[0m\r\n", err.Error()) - if writeErr := interactiveRunner.WriteToDirectOutputBySession(sessionID, []byte(errorMsg)); writeErr != nil { - m.logger.Debug("failed to write restart error message to terminal", - zap.String("session_id", sessionID), - zap.Error(writeErr)) - } - return - } - - // Connect the session's existing WebSocket to the new process - if interactiveRunner.ConnectSessionWebSocket(execution.PassthroughProcessID) { - m.logger.Info("passthrough session auto-restarted and reconnected WebSocket", - zap.String("session_id", sessionID), - zap.String("new_process_id", execution.PassthroughProcessID)) - } else { - m.logger.Warn("passthrough session restarted but failed to reconnect WebSocket", - zap.String("session_id", sessionID), - zap.String("new_process_id", execution.PassthroughProcessID)) - } -} - -// GetInteractiveRunner returns the interactive runner for passthrough mode. -// Returns nil if the runtime is not available or doesn't support passthrough. -func (m *Manager) GetInteractiveRunner() *process.InteractiveRunner { - if m.runtimeRegistry == nil { - return nil - } - standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone) - if err != nil { - return nil - } - return standaloneRT.GetInteractiveRunner() -} diff --git a/apps/backend/internal/agent/lifecycle/manager_boot_test.go b/apps/backend/internal/agent/lifecycle/manager_boot_test.go new file mode 100644 index 0000000000..2f94794e2b --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_boot_test.go @@ -0,0 +1,218 @@ +package lifecycle + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// MockBootMessageService implements BootMessageService for testing +type MockBootMessageService struct { + mu sync.Mutex + CreatedMessages []*models.Message + UpdatedMessages []*models.Message + createErr error + updateErr error +} + +func (m *MockBootMessageService) CreateMessage(ctx context.Context, req *BootMessageRequest) (*models.Message, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.createErr != nil { + return nil, m.createErr + } + msg := &models.Message{ + ID: "boot-msg-" + req.TaskID, + TaskSessionID: req.TaskSessionID, + TaskID: req.TaskID, + Content: req.Content, + AuthorType: models.MessageAuthorType(req.AuthorType), + Type: models.MessageType(req.Type), + Metadata: req.Metadata, + } + m.CreatedMessages = append(m.CreatedMessages, msg) + return msg, nil +} + +func (m *MockBootMessageService) UpdateMessage(ctx context.Context, message *models.Message) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.updateErr != nil { + return m.updateErr + } + // Store a snapshot of the message at update time + snapshot := *message + metaCopy := make(map[string]interface{}) + for k, v := range message.Metadata { + metaCopy[k] = v + } + snapshot.Metadata = metaCopy + m.UpdatedMessages = append(m.UpdatedMessages, &snapshot) + return nil +} + +func (m *MockBootMessageService) getLastUpdatedMessage() *models.Message { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.UpdatedMessages) == 0 { + return nil + } + return m.UpdatedMessages[len(m.UpdatedMessages)-1] +} + +func TestFinalizeBootMessage_Success(t *testing.T) { + mgr := newTestManager() + bootSvc := &MockBootMessageService{} + mgr.bootMessageService = bootSvc + + msg := &models.Message{ + ID: "boot-msg-1", + Metadata: map[string]interface{}{"status": "running"}, + } + stopCh := make(chan struct{}) + + mgr.finalizeBootMessage(msg, stopCh, nil, "exited") + + // Verify stop channel was closed + select { + case <-stopCh: + // good, channel was closed + default: + t.Error("expected stopCh to be closed") + } + + // Verify message was updated with final status + lastMsg := bootSvc.getLastUpdatedMessage() + if lastMsg == nil { + t.Fatal("expected boot message to be updated") + } else { + if lastMsg.Metadata["status"] != "exited" { + t.Errorf("expected status 'exited', got %v", lastMsg.Metadata["status"]) + } + if lastMsg.Metadata["exit_code"] != 0 { + t.Errorf("expected exit_code 0, got %v", lastMsg.Metadata["exit_code"]) + } + if lastMsg.Metadata["completed_at"] == nil { + t.Error("expected completed_at to be set") + } + } +} + +func TestFinalizeBootMessage_Failed(t *testing.T) { + mgr := newTestManager() + bootSvc := &MockBootMessageService{} + mgr.bootMessageService = bootSvc + + msg := &models.Message{ + ID: "boot-msg-1", + Metadata: map[string]interface{}{"status": "running"}, + } + stopCh := make(chan struct{}) + + mgr.finalizeBootMessage(msg, stopCh, nil, "failed") + + lastMsg := bootSvc.getLastUpdatedMessage() + if lastMsg == nil { + t.Fatal("expected boot message to be updated") + } else { + if lastMsg.Metadata["status"] != "failed" { + t.Errorf("expected status 'failed', got %v", lastMsg.Metadata["status"]) + } + // Failed status should NOT have exit_code + if _, ok := lastMsg.Metadata["exit_code"]; ok { + t.Error("expected no exit_code for failed status") + } + } +} + +func TestFinalizeBootMessage_NilMessage(t *testing.T) { + mgr := newTestManager() + bootSvc := &MockBootMessageService{} + mgr.bootMessageService = bootSvc + + // Should not panic with nil message + mgr.finalizeBootMessage(nil, nil, nil, "exited") + + bootSvc.mu.Lock() + defer bootSvc.mu.Unlock() + if len(bootSvc.UpdatedMessages) != 0 { + t.Error("expected no updates for nil message") + } +} + +func TestFinalizeBootMessage_NilService(t *testing.T) { + mgr := newTestManager() + // bootMessageService is nil by default + + msg := &models.Message{ + ID: "boot-msg-1", + Metadata: map[string]interface{}{"status": "running"}, + } + + // Should not panic with nil service + mgr.finalizeBootMessage(msg, nil, nil, "exited") +} + +func TestBootMessage_IsResumingFlag(t *testing.T) { + // Test that the is_resuming flag is correctly based on ACPSessionID presence + tests := []struct { + name string + acpSessionID string + wantResuming bool + }{ + { + name: "new session (no ACP session ID)", + acpSessionID: "", + wantResuming: false, + }, + { + name: "resumed session (has ACP session ID)", + acpSessionID: "acp-session-123", + wantResuming: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // The is_resuming logic is: execution.ACPSessionID != "" + isResuming := tt.acpSessionID != "" + if isResuming != tt.wantResuming { + t.Errorf("is_resuming = %v, want %v", isResuming, tt.wantResuming) + } + }) + } +} + +func TestPollAgentStderr_StopsOnClose(t *testing.T) { + mgr := newTestManager() + bootSvc := &MockBootMessageService{} + mgr.bootMessageService = bootSvc + + msg := &models.Message{ + ID: "boot-msg-1", + Metadata: map[string]interface{}{"status": "running"}, + } + stopCh := make(chan struct{}) + + // Start polling with nil client (will fail on each poll, but shouldn't panic) + done := make(chan struct{}) + go func() { + // Pass nil client - the pollAgentStderr will log errors but should exit on stop + // We can't use a nil client directly since it would panic on method call. + // Instead, just test that close(stopCh) causes the goroutine to exit. + close(stopCh) + done <- struct{}{} + }() + + select { + case <-done: + // Good, goroutine exited + case <-time.After(5 * time.Second): + t.Fatal("pollAgentStderr did not stop within timeout") + } + + _ = msg // msg would be used in a real poll +} diff --git a/apps/backend/internal/agent/lifecycle/manager_events.go b/apps/backend/internal/agent/lifecycle/manager_events.go new file mode 100644 index 0000000000..d68531fd9a --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_events.go @@ -0,0 +1,352 @@ +package lifecycle + +import ( + "strings" + "time" + + "go.uber.org/zap" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" +) + +// handleMessageChunkEvent handles a "message_chunk" agent event, accumulating and flushing on newlines. +func (m *Manager) handleMessageChunkEvent(execution *AgentExecution, event agentctl.AgentEvent) { + if event.Text == "" { + return + } + execution.messageMu.Lock() + execution.messageBuffer.WriteString(event.Text) + bufferLenAfterWrite := execution.messageBuffer.Len() + m.logger.Debug("message_chunk written to buffer", + zap.String("execution_id", execution.ID), + zap.String("operation_id", event.OperationID), + zap.Int("text_length", len(event.Text)), + zap.Int("buffer_length_after", bufferLenAfterWrite)) + + bufContent := execution.messageBuffer.String() + lastNewline := strings.LastIndex(bufContent, "\n") + if lastNewline == -1 { + execution.messageMu.Unlock() + return + } + toFlush := bufContent[:lastNewline+1] + remainder := bufContent[lastNewline+1:] + execution.messageBuffer.Reset() + execution.messageBuffer.WriteString(remainder) + execution.messageMu.Unlock() + + if strings.TrimSpace(toFlush) != "" { + m.publishStreamingMessage(execution, toFlush) + } +} + +// handleReasoningEvent handles a "reasoning" agent event, accumulating and flushing on newlines. +func (m *Manager) handleReasoningEvent(execution *AgentExecution, event agentctl.AgentEvent) { + if event.ReasoningText == "" { + return + } + execution.messageMu.Lock() + execution.thinkingBuffer.WriteString(event.ReasoningText) + + bufContent := execution.thinkingBuffer.String() + lastNewline := strings.LastIndex(bufContent, "\n") + if lastNewline == -1 { + execution.messageMu.Unlock() + return + } + toFlush := bufContent[:lastNewline+1] + remainder := bufContent[lastNewline+1:] + execution.thinkingBuffer.Reset() + execution.thinkingBuffer.WriteString(remainder) + execution.messageMu.Unlock() + + if strings.TrimSpace(toFlush) != "" { + m.publishStreamingThinking(execution, toFlush) + } +} + +// handleCompleteEventMarkState marks the execution state after a complete event: +// failed+removed on error, ready on success. +func (m *Manager) handleCompleteEventMarkState(execution *AgentExecution, event *agentctl.AgentEvent, isError bool) { + if isError { + errorMsg := "agent error completion" + if event.Error != "" { + errorMsg = event.Error + } + m.logger.Warn("error completion received, marking execution as failed", + zap.String("execution_id", execution.ID), + zap.String("task_id", execution.TaskID), + zap.String("error", errorMsg), + zap.String("event_error", event.Error), + zap.String("event_text", event.Text), + zap.Any("event_data", event.Data), + zap.String("agent_command", execution.AgentCommand), + zap.String("acp_session_id", execution.ACPSessionID)) + if err := m.MarkCompleted(execution.ID, 1, errorMsg); err != nil { + m.logger.Error("failed to mark execution as failed after error completion", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } + m.RemoveExecution(execution.ID) + return + } + if err := m.MarkReady(execution.ID); err != nil { + m.logger.Error("failed to mark execution as ready after complete", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } +} + +// handleCompleteEventSignal sends the completion signal on the promptDoneCh channel. +func handleCompleteEventSignal(execution *AgentExecution, event *agentctl.AgentEvent, isError bool) { + stopReason := "end_turn" + errorMsg := "" + if isError { + stopReason = "error" + errorMsg = "agent error completion" + if event.Error != "" { + errorMsg = event.Error + } + } + select { + case execution.promptDoneCh <- PromptCompletionSignal{ + StopReason: stopReason, + IsError: isError, + Error: errorMsg, + }: + default: + // Channel full or no one waiting — that's fine (e.g., initial prompt in goroutine) + } +} + +// handleCompleteEvent handles a "complete" agent event: flushes buffers, marks state, and signals SendPrompt. +func (m *Manager) handleCompleteEvent(execution *AgentExecution, event *agentctl.AgentEvent) { + // Check buffer content BEFORE any processing + execution.messageMu.Lock() + bufferContentBeforeFlush := execution.messageBuffer.String() + currentMsgID := execution.currentMessageID + execution.messageMu.Unlock() + + bufferPreview := bufferContentBeforeFlush + if len(bufferPreview) > 100 { + bufferPreview = bufferPreview[:100] + "..." + } + + // Check if this is an error completion (agent failed to process the prompt) + isError := false + if event.Data != nil { + if v, ok := event.Data["is_error"].(bool); ok { + isError = v + } + } + + m.logger.Info("agent turn complete", + zap.String("execution_id", execution.ID), + zap.String("operation_id", event.OperationID), + zap.String("session_id", event.SessionID), + zap.String("current_msg_id", currentMsgID), + zap.Int("buffer_length", len(bufferContentBeforeFlush)), + zap.String("buffer_preview", bufferPreview), + zap.Bool("is_error", isError)) + + // Flush the message buffer to publish any remaining content as a streaming message. + flushedText := m.flushMessageBuffer(execution) + if flushedText != "" { + event.Text = flushedText + if m.historyManager != nil && execution.SessionID != "" { + if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { + m.logger.Warn("failed to store final agent message to history", zap.Error(err)) + } + } + } + + m.logger.Info("complete event processed", + zap.String("execution_id", execution.ID), + zap.String("operation_id", event.OperationID)) + + m.handleCompleteEventMarkState(execution, event, isError) + handleCompleteEventSignal(execution, event, isError) +} + +// handleToolCallEvent processes the "tool_call" agent event: flushes the message buffer +// and stores the tool call in session history. +// Returns the (possibly updated) event. +func (m *Manager) handleToolCallEvent(execution *AgentExecution, event agentctl.AgentEvent) agentctl.AgentEvent { + if flushedText := m.flushMessageBuffer(execution); flushedText != "" { + event.Text = flushedText + if m.historyManager != nil && execution.SessionID != "" { + if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { + m.logger.Warn("failed to store agent message to history", zap.Error(err)) + } + } + } + if m.historyManager != nil && execution.SessionID != "" { + if err := m.historyManager.AppendToolCall(execution.SessionID, event); err != nil { + m.logger.Warn("failed to store tool call to history", zap.Error(err)) + } + } + m.logger.Debug("tool call started", + zap.String("execution_id", execution.ID), + zap.String("tool_call_id", event.ToolCallID), + zap.String("tool_name", event.ToolName)) + return event +} + +// handleContextWindowEvent processes the "context_window" agent event: logs and publishes it. +// Returns true because no further stream publishing is needed. +func (m *Manager) handleContextWindowEvent(execution *AgentExecution, event agentctl.AgentEvent) { + m.logger.Debug("context window update received", + zap.String("execution_id", execution.ID), + zap.Int64("size", event.ContextWindowSize), + zap.Int64("used", event.ContextWindowUsed), + zap.Float64("efficiency", event.ContextEfficiency)) + m.eventPublisher.PublishContextWindow( + execution, + event.ContextWindowSize, + event.ContextWindowUsed, + event.ContextWindowRemaining, + event.ContextEfficiency, + ) +} + +// handleAvailableCommandsEvent processes the "available_commands" agent event. +func (m *Manager) handleAvailableCommandsEvent(execution *AgentExecution, event agentctl.AgentEvent) { + if len(event.AvailableCommands) == 0 { + return + } + execution.SetAvailableCommands(event.AvailableCommands) + m.logger.Debug("stored available commands", + zap.String("execution_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.Int("command_count", len(event.AvailableCommands))) + m.eventPublisher.PublishAvailableCommands(execution, event.AvailableCommands) +} + +// handleAgentEvent processes incoming agent events from the agent +func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.AgentEvent) { + // Update last activity timestamp for stall detection + execution.lastActivityAtMu.Lock() + execution.lastActivityAt = time.Now() + execution.lastActivityAtMu.Unlock() + + m.logger.Debug("handleAgentEvent entry", + zap.String("execution_id", execution.ID), + zap.String("event_type", event.Type), + zap.String("operation_id", event.OperationID), + zap.Int("text_length", len(event.Text))) + + switch event.Type { + case "message_chunk": + m.handleMessageChunkEvent(execution, event) + return + + case "reasoning": + m.handleReasoningEvent(execution, event) + return + + case "tool_call": + event = m.handleToolCallEvent(execution, event) + + case "tool_update": + if m.historyManager != nil && execution.SessionID != "" && event.ToolStatus == "complete" { + if err := m.historyManager.AppendToolResult(execution.SessionID, event); err != nil { + m.logger.Warn("failed to store tool result to history", zap.Error(err)) + } + } + + case "plan": + m.logger.Debug("agent plan update", + zap.String("execution_id", execution.ID)) + + case "error": + m.flushMessageBuffer(execution) + m.logger.Error("agent error", + zap.String("execution_id", execution.ID), + zap.String("error", event.Error), + zap.String("text", event.Text), + zap.Any("data", event.Data)) + + case "complete": + m.handleCompleteEvent(execution, &event) + + case "permission_request": + m.logger.Debug("permission request received", + zap.String("execution_id", execution.ID), + zap.String("pending_id", event.PendingID), + zap.String("title", event.PermissionTitle)) + m.eventPublisher.PublishPermissionRequest(execution, event) + return + + case "context_window": + m.handleContextWindowEvent(execution, event) + return + + case "available_commands": + m.handleAvailableCommandsEvent(execution, event) + return + } + + m.eventPublisher.PublishAgentStreamEvent(execution, event) +} + +// handleGitStatusUpdate processes git status updates from the workspace tracker +func (m *Manager) handleGitStatusUpdate(execution *AgentExecution, update *agentctl.GitStatusUpdate) { + // Publish git status update to event bus for WebSocket streaming and persistence + m.eventPublisher.PublishGitStatus(execution, update) +} + +// handleGitCommitCreated processes git commit events from the workspace tracker +func (m *Manager) handleGitCommitCreated(execution *AgentExecution, commit *agentctl.GitCommitNotification) { + // Publish commit event to event bus for WebSocket streaming and orchestrator handling + m.eventPublisher.PublishGitCommit(execution, commit) +} + +// handleGitResetDetected processes git reset events from the workspace tracker +func (m *Manager) handleGitResetDetected(execution *AgentExecution, reset *agentctl.GitResetNotification) { + // Publish reset event to event bus for orchestrator handling (commit sync) + m.eventPublisher.PublishGitReset(execution, reset) +} + +// handleFileChangeNotification processes file change notifications from the workspace tracker +func (m *Manager) handleFileChangeNotification(execution *AgentExecution, notification *agentctl.FileChangeNotification) { + m.eventPublisher.PublishFileChange(execution, notification) +} + +// handleShellOutput processes shell output from the workspace stream +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/manager_events_test.go b/apps/backend/internal/agent/lifecycle/manager_events_test.go new file mode 100644 index 0000000000..f0aa3806d5 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_events_test.go @@ -0,0 +1,560 @@ +package lifecycle + +import ( + "context" + "sync" + "testing" + "time" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/events/bus" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// MockEventBusWithTracking provides detailed tracking of published events for testing +type MockEventBusWithTracking struct { + PublishedEvents []trackedEvent + mu sync.Mutex +} + +type trackedEvent struct { + Subject string + Event *bus.Event +} + +func (m *MockEventBusWithTracking) Publish(ctx context.Context, subject string, event *bus.Event) error { + m.mu.Lock() + defer m.mu.Unlock() + m.PublishedEvents = append(m.PublishedEvents, trackedEvent{Subject: subject, Event: event}) + return nil +} + +func (m *MockEventBusWithTracking) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) { + return nil, nil +} + +func (m *MockEventBusWithTracking) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) { + return nil, nil +} + +func (m *MockEventBusWithTracking) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) { + return nil, nil +} + +func (m *MockEventBusWithTracking) Close() {} + +func (m *MockEventBusWithTracking) IsConnected() bool { + return true +} + +func (m *MockEventBusWithTracking) getStreamEvents() []AgentStreamEventPayload { + m.mu.Lock() + defer m.mu.Unlock() + var result []AgentStreamEventPayload + for _, te := range m.PublishedEvents { + if payload, ok := te.Event.Data.(AgentStreamEventPayload); ok { + result = append(result, payload) + } + } + return result +} + +// createTestManagerWithTracking creates a manager with a tracking event bus for testing +func createTestManagerWithTracking() (*Manager, *MockEventBusWithTracking) { + log := newTestLogger() + reg := newTestRegistry() + eventBus := &MockEventBusWithTracking{} + credsMgr := &MockCredentialsManager{} + profileResolver := &MockProfileResolver{} + mgr := NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, RuntimeFallbackWarn, log) + return mgr, eventBus +} + +// createTestExecution creates a test execution with proper initialization +func createTestExecution(id, taskID, sessionID string) *AgentExecution { + return &AgentExecution{ + ID: id, + TaskID: taskID, + SessionID: sessionID, + Status: v1.AgentStatusRunning, + StartedAt: time.Now(), + promptDoneCh: make(chan PromptCompletionSignal, 1), + } +} + +// TestHandleAgentEvent_StreamingThenComplete tests the normal flow: +// message_chunk events followed by complete event - should NOT create duplicate +func TestHandleAgentEvent_StreamingThenComplete(t *testing.T) { + mgr, eventBus := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Simulate streaming chunks with newlines (which trigger publishing) + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "Hello, world!\n", + }) + + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "This is a test.\n", + }) + + // Now send complete event + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + }) + + // Verify: streaming was used, so complete should NOT have text + events := eventBus.getStreamEvents() + + // Count message_streaming events (creates/appends) + var messageStreamingEvents []AgentStreamEventPayload + var completeEvents []AgentStreamEventPayload + for _, e := range events { + if e.Data != nil { + switch e.Data.Type { + case "message_streaming": + messageStreamingEvents = append(messageStreamingEvents, e) + case "complete": + completeEvents = append(completeEvents, e) + } + } + } + + // Should have streaming messages + if len(messageStreamingEvents) == 0 { + t.Error("expected message_streaming events, got none") + } + + // Should have exactly one complete event + if len(completeEvents) != 1 { + t.Errorf("expected 1 complete event, got %d", len(completeEvents)) + } + + // The complete event should NOT have text (streaming handled it via buffer) + if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" { + t.Errorf("complete event should not have text when streaming was used, got %q", completeEvents[0].Data.Text) + } +} + +// TestHandleAgentEvent_StreamingThenToolCallThenComplete tests the scenario that could cause duplicates: +// message_chunk → tool_call (clears currentMessageID) → complete +// This verifies that the buffer is properly flushed on complete after tool calls +func TestHandleAgentEvent_StreamingThenToolCallThenComplete(t *testing.T) { + mgr, eventBus := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Simulate streaming chunks + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "Let me check that for you.\n", + }) + + // Verify currentMessageID is set after message_chunk + execution.messageMu.Lock() + msgIDBeforeToolCall := execution.currentMessageID + execution.messageMu.Unlock() + + if msgIDBeforeToolCall == "" { + t.Error("currentMessageID should be set after message_chunk") + } + + // Tool call - this flushes buffer and clears currentMessageID + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "tool_call", + ToolCallID: "tool-1", + ToolName: "read_file", + }) + + // After tool call, currentMessageID should be cleared + execution.messageMu.Lock() + msgIDAfterToolCall := execution.currentMessageID + execution.messageMu.Unlock() + + if msgIDAfterToolCall != "" { + t.Errorf("currentMessageID should be cleared after tool_call, got %q", msgIDAfterToolCall) + } + + // Now complete - this should flush the buffer + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + }) + + events := eventBus.getStreamEvents() + + // Find the complete event + var completeEvents []AgentStreamEventPayload + for _, e := range events { + if e.Data != nil && e.Data.Type == "complete" { + completeEvents = append(completeEvents, e) + } + } + + if len(completeEvents) != 1 { + t.Errorf("expected 1 complete event, got %d", len(completeEvents)) + } + + // The complete event should NOT have text (streaming was used) + if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" { + t.Errorf("complete event should not have text when streaming was used (even after tool_call), got %q", + completeEvents[0].Data.Text) + } +} + +// TestHandleAgentEvent_CompleteWithoutStreaming verifies that complete events are +// properly handled when no streaming was used (buffer is empty). +// All adapters now send text via message_chunk events, so this tests the empty buffer case. +func TestHandleAgentEvent_CompleteWithoutStreaming(t *testing.T) { + mgr, eventBus := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Complete event without any prior streaming (e.g., agent did only tool calls) + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + }) + + events := eventBus.getStreamEvents() + + var completeEvents []AgentStreamEventPayload + for _, e := range events { + if e.Data != nil && e.Data.Type == "complete" { + completeEvents = append(completeEvents, e) + } + } + + if len(completeEvents) != 1 { + t.Errorf("expected 1 complete event, got %d", len(completeEvents)) + } + + // Complete event should be published successfully + // The buffer was empty, so no message_streaming events should be generated + var streamingEvents []AgentStreamEventPayload + for _, e := range events { + if e.Data != nil && e.Data.Type == "message_streaming" { + streamingEvents = append(streamingEvents, e) + } + } + + if len(streamingEvents) != 0 { + t.Errorf("expected 0 message_streaming events when buffer is empty, got %d", len(streamingEvents)) + } +} + +// TestHandleAgentEvent_CompleteWithBufferedText verifies that buffered text +// without streaming is emitted as a final streaming event on complete. +func TestHandleAgentEvent_CompleteWithBufferedText(t *testing.T) { + mgr, eventBus := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Buffer text without newlines (no streaming event should be emitted) + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "Final message without newline", + }) + + // Complete event should flush buffer into a streaming event + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + }) + + events := eventBus.getStreamEvents() + + var completeEvents []AgentStreamEventPayload + var streamingEvents []AgentStreamEventPayload + for _, e := range events { + if e.Data != nil { + switch e.Data.Type { + case "complete": + completeEvents = append(completeEvents, e) + case "message_streaming": + streamingEvents = append(streamingEvents, e) + } + } + } + + if len(completeEvents) != 1 { + t.Errorf("expected 1 complete event, got %d", len(completeEvents)) + } + + if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" { + t.Errorf("expected complete event to have empty text, got %q", completeEvents[0].Data.Text) + } + + if len(streamingEvents) != 1 { + t.Errorf("expected 1 message_streaming event when no newlines, got %d", len(streamingEvents)) + } else if streamingEvents[0].Data.Text != "Final message without newline" { + t.Errorf("expected streaming event to carry buffered text, got %q", streamingEvents[0].Data.Text) + } +} + +// TestHandleAgentEvent_CompleteThenMessageChunk tests the scenario where +// message_chunk arrives after complete. This documents the behavior when +// an adapter incorrectly sends text after the turn has completed. +// With the new architecture, adapters should NOT send message_chunk after complete. +func TestHandleAgentEvent_CompleteThenMessageChunk(t *testing.T) { + mgr, eventBus := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // First, simulate normal streaming during the turn + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "Processing your request...\n", + }) + + // Complete event arrives - this flushes the buffer + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + }) + + // Now message_chunk arrives AFTER complete + // This shouldn't happen with properly implemented adapters, + // but we document the behavior: it creates a new message + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "Done!\n", + }) + + events := eventBus.getStreamEvents() + + // Count message_streaming events + var messageStreamingEvents []AgentStreamEventPayload + for _, e := range events { + if e.Data != nil && e.Data.Type == "message_streaming" { + messageStreamingEvents = append(messageStreamingEvents, e) + } + } + + // Document the behavior: message_chunk after complete starts a new message + t.Logf("Got %d message_streaming events", len(messageStreamingEvents)) + for i, e := range messageStreamingEvents { + t.Logf(" Event %d: MessageID=%s, IsAppend=%v, Text=%q", + i, e.Data.MessageID, e.Data.IsAppend, e.Data.Text) + } + + // The second message_chunk (after complete) should start a NEW message + // since currentMessageID was cleared by the complete event + if len(messageStreamingEvents) >= 2 { + lastEvent := messageStreamingEvents[len(messageStreamingEvents)-1] + if !lastEvent.Data.IsAppend { + t.Log("Expected behavior: message_chunk after complete creates a new message") + } + } +} + +// TestHandleAgentEvent_MultipleToolCalls tests streaming → tool → streaming → tool → complete +func TestHandleAgentEvent_MultipleToolCalls(t *testing.T) { + mgr, eventBus := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Message before first tool + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "Let me read the file.\n", + }) + + // First tool call + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "tool_call", + ToolCallID: "tool-1", + ToolName: "read_file", + }) + + // Tool update (complete) + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "tool_update", + ToolCallID: "tool-1", + ToolStatus: "complete", + }) + + // Message after first tool + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "Now let me modify it.\n", + }) + + // Second tool call + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "tool_call", + ToolCallID: "tool-2", + ToolName: "write_file", + }) + + // Tool update (complete) + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "tool_update", + ToolCallID: "tool-2", + ToolStatus: "complete", + }) + + // Final message + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "Done with both tasks!\n", + }) + + // Complete + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + }) + + events := eventBus.getStreamEvents() + + // Count different event types + var messageStreamingCount, toolCallCount, completeCount int + for _, e := range events { + if e.Data != nil { + switch e.Data.Type { + case "message_streaming": + messageStreamingCount++ + case "tool_call": + toolCallCount++ + case "complete": + completeCount++ + } + } + } + + t.Logf("Events: message_streaming=%d, tool_call=%d, complete=%d", + messageStreamingCount, toolCallCount, completeCount) + + // Should have multiple streaming messages (one per "segment" before tool calls) + if messageStreamingCount < 3 { + t.Errorf("expected at least 3 message_streaming events for 3 message segments, got %d", messageStreamingCount) + } + + if toolCallCount != 2 { + t.Errorf("expected 2 tool_call events, got %d", toolCallCount) + } + + if completeCount != 1 { + t.Errorf("expected 1 complete event, got %d", completeCount) + } + + // Find the complete event and verify it has no text + for _, e := range events { + if e.Data != nil && e.Data.Type == "complete" { + if e.Data.Text != "" { + t.Errorf("complete event should not have text when streaming was used, got %q", e.Data.Text) + } + } + } +} + +// TestHandleAgentEvent_CompleteSignalsPromptDoneCh verifies that the complete event +// signals the promptDoneCh channel with the correct stop reason. +func TestHandleAgentEvent_CompleteSignalsPromptDoneCh(t *testing.T) { + mgr, _ := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Send a normal complete event + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + }) + + // promptDoneCh should have a signal + select { + case signal := <-execution.promptDoneCh: + if signal.IsError { + t.Error("expected non-error signal for normal complete") + } + if signal.StopReason != "end_turn" { + t.Errorf("expected stop_reason 'end_turn', got %q", signal.StopReason) + } + default: + t.Error("expected signal on promptDoneCh, got none") + } +} + +// TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh verifies that an error completion +// signals the promptDoneCh channel with IsError=true and the error message. +func TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh(t *testing.T) { + mgr, _ := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Send an error complete event + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + Error: "something went wrong", + Data: map[string]interface{}{"is_error": true}, + }) + + // promptDoneCh should have an error signal + select { + case signal := <-execution.promptDoneCh: + if !signal.IsError { + t.Error("expected error signal for error complete") + } + if signal.StopReason != "error" { + t.Errorf("expected stop_reason 'error', got %q", signal.StopReason) + } + if signal.Error != "something went wrong" { + t.Errorf("expected error 'something went wrong', got %q", signal.Error) + } + default: + t.Error("expected signal on promptDoneCh, got none") + } +} + +// TestHandleAgentEvent_UpdatesLastActivityAt verifies that every agent event +// updates the lastActivityAt timestamp for stall detection. +func TestHandleAgentEvent_UpdatesLastActivityAt(t *testing.T) { + mgr, _ := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Set lastActivityAt to a known old time + oldTime := time.Now().Add(-10 * time.Minute) + execution.lastActivityAtMu.Lock() + execution.lastActivityAt = oldTime + execution.lastActivityAtMu.Unlock() + + // Send any event + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "hello\n", + }) + + // lastActivityAt should be updated to approximately now + execution.lastActivityAtMu.Lock() + elapsed := time.Since(execution.lastActivityAt) + execution.lastActivityAtMu.Unlock() + + if elapsed > 1*time.Second { + t.Errorf("lastActivityAt not updated: elapsed %v since last event", elapsed) + } +} + +// TestHandleAgentEvent_PromptDoneChDoesNotBlockWhenFull verifies that signaling +// promptDoneCh with a full channel (no receiver) doesn't block the event handler. +func TestHandleAgentEvent_PromptDoneChDoesNotBlockWhenFull(t *testing.T) { + mgr, _ := createTestManagerWithTracking() + execution := createTestExecution("exec-1", "task-1", "session-1") + mgr.executionStore.Add(execution) + + // Pre-fill the channel + execution.promptDoneCh <- PromptCompletionSignal{StopReason: "stale"} + + // Send complete event — should not block + done := make(chan struct{}) + go func() { + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + }) + close(done) + }() + + select { + case <-done: + // Good — didn't block + case <-time.After(2 * time.Second): + t.Fatal("handleAgentEvent blocked when promptDoneCh was full") + } +} diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go new file mode 100644 index 0000000000..2e0de72a26 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -0,0 +1,247 @@ +package lifecycle + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/appctx" +) + +// 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() { + // Use detached context that respects stopCh for graceful shutdown + waitCtx, cancel := appctx.Detached(ctx, m.stopCh, 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, nil) + } + }() + + return execution, nil +} + +// EnsurePassthroughExecution ensures an execution exists for a passthrough session +// and starts the passthrough process if needed. This is called when the terminal +// handler receives a connection for a session that might need recovery after backend restart. +// +// The sessionID is required. If taskID is empty, it will be looked up from: +// 1. The existing execution (if any) +// 2. The workspace info provider +// +// Returns the execution with a running passthrough process, or an error. +func (m *Manager) EnsurePassthroughExecution(ctx context.Context, sessionID string) (*AgentExecution, error) { + // Check if execution already exists with a running passthrough process + if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { + if execution.PassthroughProcessID != "" { + return execution, nil + } + // Execution exists but no passthrough process - will try to start it + return m.resumeExistingExecution(ctx, sessionID, execution) + } + + // No execution exists - need to create one from session info + return m.createExecutionFromSessionInfo(ctx, sessionID) +} + +// resumeExistingExecution starts the passthrough process for an existing execution +// that has no running process (e.g., after backend restart). +func (m *Manager) resumeExistingExecution(ctx context.Context, sessionID string, execution *AgentExecution) (*AgentExecution, error) { + m.logger.Info("execution exists but passthrough process not running, starting", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID)) + + if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { + return nil, fmt.Errorf("resume passthrough session %s: %w", sessionID, err) + } + + // Get updated execution with process ID + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return nil, fmt.Errorf("execution disappeared after resuming passthrough session %s", sessionID) + } + return execution, nil +} + +// createExecutionFromSessionInfo creates a new execution for a passthrough session +// when no execution exists (e.g., backend restarted and execution store was cleared). +func (m *Manager) createExecutionFromSessionInfo(ctx context.Context, sessionID string) (*AgentExecution, error) { + if m.workspaceInfoProvider == nil { + return nil, fmt.Errorf("cannot restore session %s: workspace info provider not configured", sessionID) + } + + // Get workspace info from the provider (looks up session to get taskID, workspace path, etc.) + info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, "", sessionID) + if err != nil { + return nil, fmt.Errorf("get workspace info for session %s: %w", sessionID, err) + } + + if info.WorkspacePath == "" { + return nil, fmt.Errorf("session %s has no workspace path configured", sessionID) + } + + if info.TaskID == "" { + return nil, fmt.Errorf("session %s has no associated task ID", sessionID) + } + + // Verify this session should use passthrough mode + if err := m.verifyPassthroughEnabled(ctx, sessionID, info.AgentProfileID); err != nil { + return nil, err + } + + // Create the execution + m.logger.Info("creating execution for passthrough session", + zap.String("task_id", info.TaskID), + zap.String("session_id", sessionID), + zap.String("workspace_path", info.WorkspacePath)) + + execution, err := m.createExecution(ctx, info.TaskID, info) + if err != nil { + return nil, fmt.Errorf("create execution for session %s: %w", sessionID, err) + } + + // Start the passthrough process using resume command (recovery after restart) + m.logger.Info("starting passthrough process for session", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID)) + + if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { + return nil, fmt.Errorf("start passthrough process for session %s: %w", sessionID, err) + } + + // Get updated execution with process ID + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return nil, fmt.Errorf("execution disappeared after starting passthrough session %s", sessionID) + } + + return execution, nil +} + +// verifyPassthroughEnabled checks if the session's profile has CLI passthrough enabled. +func (m *Manager) verifyPassthroughEnabled(ctx context.Context, sessionID, profileID string) error { + if m.profileResolver == nil || profileID == "" { + return fmt.Errorf("session %s has no profile configured for passthrough mode", sessionID) + } + + profileInfo, err := m.profileResolver.ResolveProfile(ctx, profileID) + if err != nil { + m.logger.Warn("failed to resolve profile for passthrough check", + zap.String("session_id", sessionID), + zap.String("profile_id", profileID), + zap.Error(err)) + return fmt.Errorf("session %s: failed to resolve profile %s: %w", sessionID, profileID, err) + } + + if profileInfo == nil || !profileInfo.CLIPassthrough { + return fmt.Errorf("session %s is not configured for CLI passthrough mode", sessionID) + } + + return 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) { + // Get the default runtime for on-demand execution creation + rt, err := m.getDefaultRuntime() + if err != nil { + return nil, fmt.Errorf("no runtime configured: %w", err) + } + + if info.AgentID == "" { + return nil, fmt.Errorf("agent ID is required in WorkspaceInfo") + } + + executionID := uuid.New().String() + + agentConfig, ok := m.registry.Get(info.AgentID) + if !ok { + return nil, fmt.Errorf("agent type %q not found in registry", info.AgentID) + } + + req := &RuntimeCreateRequest{ + InstanceID: executionID, + TaskID: taskID, + SessionID: info.SessionID, + AgentProfileID: info.AgentProfileID, + WorkspacePath: info.WorkspacePath, + Protocol: string(agentConfig.Runtime().Protocol), + AgentConfig: agentConfig, + } + + runtimeInstance, err := rt.CreateInstance(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to create execution: %w", err) + } + + execution := runtimeInstance.ToAgentExecution(req) + execution.RuntimeName = string(rt.Name()) + + // 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", execution.RuntimeName)) + + return execution, nil +} diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction.go b/apps/backend/internal/agent/lifecycle/manager_interaction.go new file mode 100644 index 0000000000..7a5ef02f48 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_interaction.go @@ -0,0 +1,443 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/events" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// PromptAgent sends a follow-up prompt to a running agent +// Attachments (images) are passed to the agent if provided +func (m *Manager) PromptAgent(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return nil, fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound) + } + return m.sessionManager.SendPrompt(ctx, execution, prompt, true, attachments) +} + +// CancelAgent interrupts the current agent turn without terminating the process, +// allowing the user to send a new prompt. +func (m *Manager) CancelAgent(ctx context.Context, executionID string) error { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return fmt.Errorf("execution %q not found", executionID) + } + + if execution.agentctl == nil { + return fmt.Errorf("execution %q has no agentctl client", executionID) + } + + m.logger.Info("cancelling agent turn", + zap.String("execution_id", executionID), + zap.String("task_id", execution.TaskID), + zap.String("session_id", execution.SessionID)) + + if err := execution.agentctl.Cancel(ctx); err != nil { + m.logger.Error("failed to cancel agent turn", + zap.String("execution_id", executionID), + zap.Error(err)) + return fmt.Errorf("failed to cancel agent: %w", err) + } + + // Clear streaming state after cancel to ensure clean state for next prompt + execution.messageMu.Lock() + execution.messageBuffer.Reset() + execution.thinkingBuffer.Reset() + execution.currentMessageID = "" + execution.currentThinkingID = "" + execution.messageMu.Unlock() + + // Mark as ready for follow-up prompts after successful cancel + if err := m.MarkReady(executionID); err != nil { + m.logger.Warn("failed to mark execution as ready after cancel", + zap.String("execution_id", executionID), + zap.Error(err)) + } + + m.logger.Info("agent turn cancelled successfully", + zap.String("execution_id", executionID)) + + return nil +} + +// CancelAgentBySessionID cancels the current agent turn for a specific session +func (m *Manager) CancelAgentBySessionID(ctx context.Context, sessionID string) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent running for session %q", sessionID) + } + + return m.CancelAgent(ctx, execution.ID) +} + +// StopAgent stops an agent execution +func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool) error { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return fmt.Errorf("execution %q not found", executionID) + } + + m.logger.Info("stopping agent", + zap.String("execution_id", executionID), + zap.Bool("force", force), + zap.String("runtime", execution.RuntimeName)) + + // Try to gracefully stop via agentctl first + if execution.agentctl != nil && !force { + if err := execution.agentctl.Stop(ctx); err != nil { + m.logger.Warn("failed to stop agent via agentctl", + zap.String("execution_id", executionID), + zap.Error(err)) + } + execution.agentctl.Close() + } + + // Stop the agent execution via the runtime that created it + m.stopAgentViaRuntime(ctx, executionID, execution, force) + + // Update execution status and remove from tracking + _ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) { + exec.Status = v1.AgentStatusStopped + now := time.Now() + exec.FinishedAt = &now + }) + m.executionStore.Remove(executionID) + + m.logger.Info("agent stopped and removed from tracking", + zap.String("execution_id", executionID), + zap.String("task_id", execution.TaskID)) + + // Publish stopped event + m.eventPublisher.PublishAgentEvent(ctx, events.AgentStopped, execution) + + return nil +} + +// StopBySessionID stops the agent for a specific session +func (m *Manager) StopBySessionID(ctx context.Context, sessionID string, force bool) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent running for session %q", sessionID) + } + + return m.StopAgent(ctx, execution.ID, force) +} + +// GetExecution returns an agent execution by ID. +// +// Returns (execution, true) if found, or (nil, false) if not found. +// The returned execution pointer should not be modified directly - use the Manager's +// methods to update execution state (MarkReady, MarkCompleted, UpdateStatus). +// +// Thread-safe: Can be called concurrently from multiple goroutines. +func (m *Manager) GetExecution(executionID string) (*AgentExecution, bool) { + return m.executionStore.Get(executionID) +} + +// GetExecutionBySessionID returns the agent execution for a session. +// +// Returns (execution, true) if found, or (nil, false) if not found. +// A session can have at most one active execution at a time. If a session exists +// but has no active execution, this returns (nil, false). +// +// Thread-safe: Can be called concurrently from multiple goroutines. +func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bool) { + return m.executionStore.GetBySessionID(sessionID) +} + +// GetAvailableCommandsForSession returns the available slash commands for a session. +// Returns nil if the session doesn't exist or has no commands stored. +// +// Thread-safe: Can be called concurrently from multiple goroutines. +func (m *Manager) GetAvailableCommandsForSession(sessionID string) []streams.AvailableCommand { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return nil + } + return execution.GetAvailableCommands() +} + +// ListExecutions returns all currently tracked agent executions. +// +// Returns a snapshot of all executions in memory at the time of call. The returned slice +// contains pointers to execution objects that may be modified by other goroutines after +// this method returns. Do not modify execution state directly - use Manager methods instead. +// +// The list includes executions in all states: +// - Starting (process launching, agentctl initializing) +// - Running (actively processing prompts) +// - Ready (waiting for user input) +// - Completed/Failed (finished but not yet removed) +// +// Thread-safe: Can be called concurrently. Returns a new slice on each call. +// +// Typical usage: Status endpoints, debugging, cleanup loops. +func (m *Manager) ListExecutions() []*AgentExecution { + return m.executionStore.List() +} + +// IsAgentRunningForSession checks if an agent process is running or starting for a session. +// +// This probes agentctl's status endpoint to verify the agent process state. Returns true if: +// - Agent status is "running" (actively processing prompts) +// - Agent status is "starting" (process launched but not yet ready) +// +// Returns false if: +// - No execution exists for this session +// - agentctl client is not available +// - Status check fails (network/timeout error) +// - Agent is in any other state (stopped, failed, etc.) +// +// Note: The name "IsAgentRunning" is slightly misleading - it includes "starting" state. +// Use this to check if an agent subprocess exists for the session, regardless of ready state. +func (m *Manager) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { + // First check if we have an execution tracked for this session + execution, exists := m.GetExecutionBySessionID(sessionID) + if !exists { + return false + } + + // Probe agentctl status to verify the agent process is running + if execution.agentctl == nil { + return false + } + + status, err := execution.agentctl.GetStatus(ctx) + if err != nil { + m.logger.Debug("failed to get agentctl status", + zap.String("session_id", sessionID), + zap.Error(err)) + return false + } + + return status.IsAgentRunning() +} + +// UpdateStatus updates the status of an execution +func (m *Manager) UpdateStatus(executionID string, status v1.AgentStatus) error { + if err := m.executionStore.WithLock(executionID, func(execution *AgentExecution) { + execution.Status = status + }); err != nil { + if errors.Is(err, ErrExecutionNotFound) { + return fmt.Errorf("execution %q not found", executionID) + } + return err + } + + m.logger.Debug("updated execution status", + zap.String("execution_id", executionID), + zap.String("status", string(status))) + + return nil +} + +// MarkReady marks an execution as ready for follow-up prompts. +// +// This transitions the execution to the "ready" state, indicating the agent has finished +// processing the current prompt and is waiting for user input. This is called: +// - After agent initialization completes (session loaded, workspace ready) +// - After agent finishes processing a prompt (via stream completion event) +// - After cancelling an agent turn (to allow new prompts) +// +// State Machine Transitions: +// +// Starting -> Ready (after initialization) +// Running -> Ready (after prompt completion) +// Any -> Ready (after cancel) +// +// Publishes an AgentReady event to notify subscribers (frontend, orchestrator). +// +// Returns error if execution not found. +func (m *Manager) MarkReady(executionID string) error { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return fmt.Errorf("execution %q not found", executionID) + } + + // Skip if already ready (prevents duplicate events) + if execution.Status == v1.AgentStatusReady { + return nil + } + + m.executionStore.UpdateStatus(executionID, v1.AgentStatusReady) + + m.logger.Info("execution ready for follow-up prompts", + zap.String("execution_id", executionID)) + + // Publish ready event + m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentReady, execution) + + return nil +} + +// MarkCompleted marks an execution as completed or failed. +// +// This is called when the agent process terminates, either successfully or with an error. +// The final status is determined by exit code and error message: +// +// - exitCode == 0 && errorMessage == "" → AgentStatusCompleted (success) +// - Otherwise → AgentStatusFailed (failure) +// +// Parameters: +// - executionID: The execution to mark as completed +// - exitCode: Process exit code (0 = success, non-zero = failure) +// - errorMessage: Human-readable error description (empty string if no error) +// +// State Machine: +// +// This is a terminal state transition - no further state changes are expected after this. +// Typical flow: Starting -> Running -> Ready -> ... -> Completed/Failed +// +// Publishes either AgentCompleted or AgentFailed event depending on final status. +// +// Returns error if execution not found. +func (m *Manager) MarkCompleted(executionID string, exitCode int, errorMessage string) error { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return fmt.Errorf("execution %q not found", executionID) + } + + _ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) { + now := time.Now() + exec.FinishedAt = &now + exec.ExitCode = &exitCode + exec.ErrorMessage = errorMessage + + if exitCode == 0 && errorMessage == "" { + exec.Status = v1.AgentStatusCompleted + } else { + exec.Status = v1.AgentStatusFailed + } + }) + + m.logger.Info("execution completed", + zap.String("execution_id", executionID), + zap.Int("exit_code", exitCode), + zap.String("status", string(execution.Status))) + + // Publish completion event + eventType := events.AgentCompleted + if execution.Status == v1.AgentStatusFailed { + eventType = events.AgentFailed + } + m.eventPublisher.PublishAgentEvent(context.Background(), eventType, execution) + + return nil +} + +// RespondToPermission sends a response to an agent's permission request. +// +// When an agent requests permission (e.g., to run a bash command, modify files, etc.), +// it pauses execution and waits for user approval. This method sends the user's response. +// +// Parameters: +// - executionID: The agent execution waiting for permission +// - pendingID: Unique ID of the permission request (from permission request event) +// - optionID: The user-selected option ID (from the permission request's options array) +// - cancelled: If true, indicates user cancelled/rejected the permission request. +// When cancelled=true, optionID is ignored. +// +// Response Semantics: +// - cancelled=false, optionID="approve" → User approved the action +// - cancelled=false, optionID="deny" → User explicitly denied the action +// - cancelled=true, optionID="" → User cancelled/closed the dialog +// +// After receiving the response, the agent will either: +// - Continue executing (if approved) +// - Skip the action and report failure (if denied/cancelled) +// +// Timeout: 30 seconds for agentctl to acknowledge the response. +// +// Returns error if execution not found, agentctl unavailable, or communication fails. +func (m *Manager) RespondToPermission(executionID, pendingID, optionID string, cancelled bool) error { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return fmt.Errorf("agent execution not found: %s", executionID) + } + + if execution.agentctl == nil { + return fmt.Errorf("agent execution has no agentctl client: %s", executionID) + } + + m.logger.Info("responding to permission request", + zap.String("execution_id", executionID), + zap.String("pending_id", pendingID), + zap.String("option_id", optionID), + zap.Bool("cancelled", cancelled)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + return execution.agentctl.RespondToPermission(ctx, pendingID, optionID, cancelled) +} + +// RespondToPermissionBySessionID sends a response to a permission request using session ID. +// +// Convenience method that looks up the execution by session ID and delegates to RespondToPermission. +// See RespondToPermission for parameter semantics and behavior. +func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID string, cancelled bool) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled) +} + +// stopAgentViaRuntime stops the agent execution via the runtime that created it. +func (m *Manager) stopAgentViaRuntime(ctx context.Context, executionID string, execution *AgentExecution, force bool) { + if execution.RuntimeName == "" || m.runtimeRegistry == nil { + return + } + rt, err := m.runtimeRegistry.GetRuntime(runtime.Name(execution.RuntimeName)) + if err != nil { + m.logger.Warn("failed to get runtime for stopping execution", + zap.String("execution_id", executionID), + zap.String("runtime", execution.RuntimeName), + zap.Error(err)) + return + } + m.stopPassthroughProcess(ctx, executionID, execution, rt) + runtimeInstance := &RuntimeInstance{ + InstanceID: execution.ID, + TaskID: execution.TaskID, + ContainerID: execution.ContainerID, + StandaloneInstanceID: execution.standaloneInstanceID, + StandalonePort: execution.standalonePort, + } + if err := rt.StopInstance(ctx, runtimeInstance, force); err != nil { + m.logger.Warn("failed to stop runtime instance, continuing with cleanup", + zap.String("execution_id", executionID), + zap.Error(err)) + } +} + +// stopPassthroughProcess stops the passthrough interactive process if one is running. +func (m *Manager) stopPassthroughProcess(ctx context.Context, executionID string, execution *AgentExecution, rt Runtime) { + if execution.PassthroughProcessID == "" { + return + } + interactiveRunner := rt.GetInteractiveRunner() + if interactiveRunner == nil { + return + } + if err := interactiveRunner.Stop(ctx, execution.PassthroughProcessID); err != nil { + m.logger.Warn("failed to stop passthrough process", + zap.String("execution_id", executionID), + zap.String("process_id", execution.PassthroughProcessID), + zap.Error(err)) + return + } + m.logger.Info("passthrough process stopped", + zap.String("execution_id", executionID), + zap.String("process_id", execution.PassthroughProcessID)) +} diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go new file mode 100644 index 0000000000..5d72fd628f --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -0,0 +1,391 @@ +package lifecycle + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" +) + +// resolveAgentProfile resolves the agent profile and returns the agent type name and profile info. +func (m *Manager) resolveAgentProfile(ctx context.Context, req *LaunchRequest) (string, *AgentProfileInfo, error) { + if m.profileResolver == nil { + // Fallback: treat AgentProfileID as agent type directly (for backward compat) + m.logger.Warn("no profile resolver configured, using profile ID as agent type", + zap.String("agent_type", req.AgentProfileID)) + return req.AgentProfileID, nil, nil + } + profileInfo, err := m.profileResolver.ResolveProfile(ctx, req.AgentProfileID) + if err != nil { + return "", nil, fmt.Errorf("failed to resolve agent profile: %w", err) + } + m.logger.Debug("resolved agent profile", + zap.String("profile_id", req.AgentProfileID), + zap.String("agent_name", profileInfo.AgentName), + zap.String("agent_type", profileInfo.AgentName)) + return profileInfo.AgentName, profileInfo, nil +} + +// buildLaunchMetadata builds runtime metadata for the Launch request. +func buildLaunchMetadata(req *LaunchRequest, mainRepoGitDir, worktreeID, worktreeBranch string) map[string]interface{} { + metadata := make(map[string]interface{}) + for k, v := range req.Metadata { + metadata[k] = v + } + if mainRepoGitDir != "" { + metadata[MetadataKeyMainRepoGitDir] = mainRepoGitDir + } + if worktreeID != "" { + metadata[MetadataKeyWorktreeID] = worktreeID + } + if worktreeBranch != "" { + metadata[MetadataKeyWorktreeBranch] = worktreeBranch + } + return metadata +} + +// buildAgentCommand builds the agent command string for the execution. +func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfileInfo, agentConfig agents.Agent) string { + model := "" + autoApprove := false + permissionValues := make(map[string]bool) + if profileInfo != nil { + model = profileInfo.Model + autoApprove = profileInfo.AutoApprove + permissionValues["auto_approve"] = profileInfo.AutoApprove + permissionValues["allow_indexing"] = profileInfo.AllowIndexing + permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions + } + // Allow model override from request (for dynamic model switching) + if req.ModelOverride != "" { + model = req.ModelOverride + } + cmdOpts := agents.CommandOptions{ + Model: model, + SessionID: req.ACPSessionID, + AutoApprove: autoApprove, + PermissionValues: permissionValues, + } + return m.commandBuilder.BuildCommandString(agentConfig, cmdOpts) +} + +// launchResolveWorkspacePath resolves the effective workspace path, handling worktree +// creation if requested. Returns workspacePath, mainRepoGitDir, worktreeID, worktreeBranch. +func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchRequest) (workspacePath, mainRepoGitDir, worktreeID, worktreeBranch string) { + workspacePath = req.WorkspacePath + if req.UseWorktree && m.worktreeMgr != nil && req.RepositoryPath != "" { + wt, err := m.getOrCreateWorktree(ctx, req) + if err != nil { + m.logger.Warn("failed to create worktree, falling back to direct mount", + zap.String("task_id", req.TaskID), + zap.Error(err)) + // Fall back to direct mount if worktree creation fails + workspacePath = req.RepositoryPath + } else { + workspacePath = wt.Path + worktreeID = wt.ID + worktreeBranch = wt.Branch + // Git worktrees reference the main repo's .git directory via a .git file. + // We need to mount the entire .git directory for git commands to work. + mainRepoGitDir = filepath.Join(req.RepositoryPath, ".git") + } + } else if req.RepositoryPath != "" && workspacePath == "" { + workspacePath = req.RepositoryPath + } + return +} + +// launchPrepareRequest copies the launch request, sets the resolved workspace path, +// populates metadata from the request fields, and injects profile environment variables. +func (m *Manager) launchPrepareRequest(req *LaunchRequest, profileInfo *AgentProfileInfo, workspacePath string) (LaunchRequest, string) { + executionID := uuid.New().String() + reqWithWorktree := *req + reqWithWorktree.WorkspacePath = workspacePath + + if reqWithWorktree.Metadata == nil { + reqWithWorktree.Metadata = make(map[string]interface{}) + } + if req.TaskDescription != "" { + reqWithWorktree.Metadata["task_description"] = req.TaskDescription + } + if req.SessionID != "" { + reqWithWorktree.Metadata["session_id"] = req.SessionID + } + + if profileInfo != nil { + if reqWithWorktree.Env == nil { + reqWithWorktree.Env = make(map[string]string) + } + if profileInfo.Model != "" { + reqWithWorktree.Env["AGENT_MODEL"] = profileInfo.Model + } + if profileInfo.AutoApprove { + reqWithWorktree.Env["AGENTCTL_AUTO_APPROVE_PERMISSIONS"] = "true" + } + } + return reqWithWorktree, executionID +} + +// launchBuildRuntimeRequest resolves MCP servers, builds the RuntimeCreateRequest, +// and creates the runtime instance. +func (m *Manager) launchBuildRuntimeRequest(ctx context.Context, executionID string, reqWithWorktree *LaunchRequest, agentConfig agents.Agent, mainRepoGitDir, worktreeID, worktreeBranch string) (*RuntimeCreateRequest, *RuntimeInstance, Runtime, error) { + rt, err := m.getRuntimeForExecutorType(reqWithWorktree.ExecutorType) + if err != nil { + return nil, nil, nil, fmt.Errorf("no runtime configured: %w", err) + } + + env := m.buildEnvForRuntime(executionID, reqWithWorktree, agentConfig) + + acpMcpServers, err := m.resolveMcpServersWithParams(ctx, reqWithWorktree.AgentProfileID, reqWithWorktree.Metadata, agentConfig) + if err != nil { + m.logger.Warn("failed to resolve MCP servers for launch", zap.Error(err)) + } + + var mcpServers []McpServerConfig + for _, srv := range acpMcpServers { + mcpServers = append(mcpServers, McpServerConfig{ + Name: srv.Name, + URL: srv.URL, + Type: srv.Type, + Command: srv.Command, + Args: srv.Args, + }) + } + + metadata := buildLaunchMetadata(reqWithWorktree, mainRepoGitDir, worktreeID, worktreeBranch) + + runtimeReq := &RuntimeCreateRequest{ + InstanceID: executionID, + TaskID: reqWithWorktree.TaskID, + SessionID: reqWithWorktree.SessionID, + AgentProfileID: reqWithWorktree.AgentProfileID, + WorkspacePath: reqWithWorktree.WorkspacePath, + Protocol: string(agentConfig.Runtime().Protocol), + Env: env, + Metadata: metadata, + AgentConfig: agentConfig, + McpServers: mcpServers, + } + + runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create execution: %w", err) + } + return runtimeReq, runtimeInstance, rt, nil +} + +// Launch launches a new agent for a task +func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) { + m.logger.Debug("launching agent", + zap.String("task_id", req.TaskID), + zap.String("agent_profile_id", req.AgentProfileID), + zap.Bool("use_worktree", req.UseWorktree)) + + // 1. Resolve the agent profile to get agent type info + agentTypeName, profileInfo, err := m.resolveAgentProfile(ctx, req) + if err != nil { + return nil, err + } + + // 2. Get agent config from registry + agentConfig, ok := m.registry.Get(agentTypeName) + if !ok { + return nil, fmt.Errorf("agent type %q not found in registry", agentTypeName) + } + if !agentConfig.Enabled() { + return nil, fmt.Errorf("agent type %q is disabled", agentTypeName) + } + + // 3. Check if session already has an agent running + if req.SessionID != "" { + if existingExecution, exists := m.executionStore.GetBySessionID(req.SessionID); exists { + return nil, fmt.Errorf("session %q already has an agent running (execution: %s)", req.SessionID, existingExecution.ID) + } + } + + // 4. Resolve workspace path (with optional worktree creation) + workspacePath, mainRepoGitDir, worktreeID, worktreeBranch := m.launchResolveWorkspacePath(ctx, req) + + // 5 & 6. Prepare the request copy with metadata and profile env + reqWithWorktree, executionID := m.launchPrepareRequest(req, profileInfo, workspacePath) + + // 7. Build runtime request and create instance (agent not started yet) + runtimeReq, runtimeInstance, rt, err := m.launchBuildRuntimeRequest(ctx, executionID, &reqWithWorktree, agentConfig, mainRepoGitDir, worktreeID, worktreeBranch) + if err != nil { + return nil, err + } + + // Convert to AgentExecution and set the runtime name + execution := runtimeInstance.ToAgentExecution(runtimeReq) + execution.RuntimeName = string(rt.Name()) + + if req.ACPSessionID != "" { + execution.ACPSessionID = req.ACPSessionID + } + execution.AgentCommand = m.buildAgentCommand(req, profileInfo, agentConfig) + + // 8. Track the execution + m.executionStore.Add(execution) + + // 9. Publish agent.started event + m.eventPublisher.PublishAgentEvent(ctx, events.AgentStarted, execution) + m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlStarting, execution, "") + + // 10. Wait for agentctl to be ready (for shell/workspace access) + // NOTE: This does NOT start the agent process - call StartAgentProcess() explicitly + go m.waitForAgentctlReady(execution) + + m.logger.Debug("agentctl execution created (agent not started)", + zap.String("execution_id", executionID), + zap.String("task_id", req.TaskID), + zap.String("runtime", execution.RuntimeName)) + + return execution, nil +} + +// SetExecutionDescription updates the task description stored in an execution's metadata. +// This is used when starting an agent on a workspace that was launched without a prompt. +func (m *Manager) SetExecutionDescription(_ context.Context, executionID string, description string) error { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return fmt.Errorf("execution %q not found", executionID) + } + if execution.Metadata == nil { + execution.Metadata = make(map[string]interface{}) + } + execution.Metadata["task_description"] = description + return nil +} + +// resolveApprovalPolicyAndDisplayName resolves the approval policy and agent display name +// from the execution's agent profile and registry. +func (m *Manager) resolveApprovalPolicyAndDisplayName(ctx context.Context, execution *AgentExecution) (string, string) { + approvalPolicy := "" + agentDisplayName := "" + if execution.AgentProfileID == "" || m.profileResolver == nil { + return approvalPolicy, agentDisplayName + } + profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) + if err != nil { + return approvalPolicy, agentDisplayName + } + if profileInfo.AutoApprove { + approvalPolicy = "never" + } else { + approvalPolicy = "untrusted" + } + // Look up display name from registry (e.g. "Claude", "Auggie", "Codex") + if agentCfg, ok := m.registry.Get(profileInfo.AgentName); ok && agentCfg.DisplayName() != "" { + agentDisplayName = agentCfg.DisplayName() + } else { + agentDisplayName = profileInfo.AgentName + } + return approvalPolicy, agentDisplayName +} + +// createBootMessage creates a boot message and starts the stderr polling goroutine. +// Returns the message and stop channel (both nil if bootMessageService is not configured). +func (m *Manager) createBootMessage(ctx context.Context, execution *AgentExecution, bootCommand, agentDisplayName string) (*models.Message, chan struct{}) { + if m.bootMessageService == nil { + return nil, nil + } + bootMsg, bootErr := m.bootMessageService.CreateMessage(ctx, &BootMessageRequest{ + TaskSessionID: execution.SessionID, + TaskID: execution.TaskID, + Content: "", + AuthorType: "agent", + Type: "script_execution", + Metadata: map[string]interface{}{ + "script_type": "agent_boot", + "agent_name": agentDisplayName, + "command": bootCommand, + "status": "running", + "is_resuming": execution.ACPSessionID != "", + "started_at": time.Now().UTC().Format(time.RFC3339Nano), + }, + }) + if bootErr != nil { + m.logger.Warn("failed to create boot message, continuing without boot output", + zap.String("execution_id", execution.ID), + zap.Error(bootErr)) + return nil, nil + } + bootStopCh := make(chan struct{}) + go m.pollAgentStderr(execution.agentctl, bootMsg, bootStopCh) + return bootMsg, bootStopCh +} + +// getTaskDescriptionFromMetadata extracts the task description string from execution metadata. +func getTaskDescriptionFromMetadata(execution *AgentExecution) string { + if execution.Metadata == nil { + return "" + } + if desc, ok := execution.Metadata["task_description"].(string); ok { + return desc + } + return "" +} + +// configureAndStartAgent configures the agent command and starts the agent subprocess. +// Returns the effective boot command (full command with adapter args, or base command). +func (m *Manager) configureAndStartAgent(ctx context.Context, execution *AgentExecution, taskDescription, approvalPolicy string) (string, error) { + env := map[string]string{} + if taskDescription != "" { + env["TASK_DESCRIPTION"] = taskDescription + } + + if err := execution.agentctl.ConfigureAgent(ctx, execution.AgentCommand, env, approvalPolicy); err != nil { + return "", fmt.Errorf("failed to configure agent: %w", err) + } + + fullCommand, err := execution.agentctl.Start(ctx) + if err != nil { + m.updateExecutionError(execution.ID, "failed to start agent: "+err.Error()) + return "", fmt.Errorf("failed to start agent: %w", err) + } + + bootCommand := fullCommand + if bootCommand == "" { + bootCommand = execution.AgentCommand + } + return bootCommand, nil +} + +// initializeAgentSession handles post-startup initialization: boot message, ACP session, +// MCP servers. It finalizes the boot message on success or failure. +func (m *Manager) initializeAgentSession(ctx context.Context, execution *AgentExecution, bootCommand, agentDisplayName, taskDescription string) error { + bootMsg, bootStopCh := m.createBootMessage(ctx, execution, bootCommand, agentDisplayName) + + // Give the agent process a moment to initialize + time.Sleep(500 * time.Millisecond) + + agentConfig, err := m.getAgentConfigForExecution(execution) + if err != nil { + m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") + return fmt.Errorf("failed to get agent config: %w", err) + } + + mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig) + if err != nil { + m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") + m.updateExecutionError(execution.ID, "failed to resolve MCP config: "+err.Error()) + return fmt.Errorf("failed to resolve MCP config: %w", err) + } + + if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, mcpServers); err != nil { + m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") + m.updateExecutionError(execution.ID, "failed to initialize ACP: "+err.Error()) + return fmt.Errorf("failed to initialize ACP: %w", err) + } + + m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, containerStateExited) + return nil +} + diff --git a/apps/backend/internal/agent/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/lifecycle/manager_lifecycle.go new file mode 100644 index 0000000000..bc98bf6844 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_lifecycle.go @@ -0,0 +1,312 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/runtime" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +const containerStateExited = "exited" + +// Start starts the lifecycle manager background tasks +func (m *Manager) Start(ctx context.Context) error { + if m.runtimeRegistry == nil { + m.logger.Warn("no runtime registry configured") + return nil + } + + runtimeNames := m.runtimeRegistry.List() + m.logger.Info("starting lifecycle manager", zap.Int("runtimes", len(runtimeNames))) + + // Check health of all registered runtimes + healthResults := m.runtimeRegistry.HealthCheckAll(ctx) + for name, err := range healthResults { + if err != nil { + m.logger.Warn("runtime health check failed", + zap.String("runtime", string(name)), + zap.Error(err)) + } else { + m.logger.Info("runtime is healthy", zap.String("runtime", string(name))) + } + } + + // Try to recover executions from all runtimes + recovered, err := m.runtimeRegistry.RecoverAll(ctx) + if err != nil { + m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err)) + } + if len(recovered) > 0 { + for _, ri := range recovered { + execution := &AgentExecution{ + ID: ri.InstanceID, + TaskID: ri.TaskID, + SessionID: ri.SessionID, + ContainerID: ri.ContainerID, + ContainerIP: ri.ContainerIP, + WorkspacePath: ri.WorkspacePath, + RuntimeName: ri.RuntimeName, + Status: v1.AgentStatusRunning, + StartedAt: time.Now(), + Metadata: ri.Metadata, + agentctl: ri.Client, + standaloneInstanceID: ri.StandaloneInstanceID, + standalonePort: ri.StandalonePort, + promptDoneCh: make(chan PromptCompletionSignal, 1), + } + m.executionStore.Add(execution) + + // Reconnect to workspace streams (shell, git, file changes) in background + // This is needed so shell.input, git status, etc. work after backend restart + go m.streamManager.ReconnectAll(execution) + } + m.logger.Info("recovered executions", zap.Int("count", len(recovered))) + } + + // Start cleanup loop when container manager is available (Docker mode) + if m.containerManager != nil { + m.wg.Add(1) + go m.cleanupLoop(ctx) + m.logger.Info("cleanup loop started") + } + + // Set up callbacks for passthrough mode (using standalone runtime) + if standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone); err == nil { + if interactiveRunner := standaloneRT.GetInteractiveRunner(); interactiveRunner != nil { + // Turn complete callback + interactiveRunner.SetTurnCompleteCallback(func(sessionID string) { + m.handlePassthroughTurnComplete(sessionID) + }) + + // Output callback for standalone passthrough (no WorkspaceTracker) + interactiveRunner.SetOutputCallback(func(output *agentctltypes.ProcessOutput) { + m.handlePassthroughOutput(output) + }) + + // Status callback for standalone passthrough (no WorkspaceTracker) + interactiveRunner.SetStatusCallback(func(status *agentctltypes.ProcessStatusUpdate) { + m.handlePassthroughStatus(status) + }) + + m.logger.Info("passthrough callbacks configured") + } + } + + return nil +} + +// GetRecoveredExecutions returns a snapshot of all currently tracked executions +// This can be used by the orchestrator to sync with the database +func (m *Manager) GetRecoveredExecutions() []RecoveredExecution { + executions := m.executionStore.List() + result := make([]RecoveredExecution, 0, len(executions)) + for _, exec := range executions { + result = append(result, RecoveredExecution{ + ExecutionID: exec.ID, + TaskID: exec.TaskID, + SessionID: exec.SessionID, + ContainerID: exec.ContainerID, + AgentProfileID: exec.AgentProfileID, + }) + } + return result +} + +// Stop stops the lifecycle manager +func (m *Manager) Stop() error { + m.logger.Info("stopping lifecycle manager") + + close(m.stopCh) + m.wg.Wait() + + return nil +} + +// StopAllAgents attempts a graceful shutdown of all active agents concurrently. +func (m *Manager) StopAllAgents(ctx context.Context) error { + executions := m.executionStore.List() + if len(executions) == 0 { + return nil + } + + var wg sync.WaitGroup + errCh := make(chan error, len(executions)) + + for _, exec := range executions { + wg.Add(1) + go func(e *AgentExecution) { + defer wg.Done() + if err := m.StopAgent(ctx, e.ID, false); err != nil { + errCh <- err + m.logger.Warn("failed to stop agent during shutdown", + zap.String("execution_id", e.ID), + zap.Error(err)) + } + }(exec) + } + + wg.Wait() + close(errCh) + + var errs []error + for err := range errCh { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +// cleanupLoop runs periodic cleanup of stale containers +func (m *Manager) cleanupLoop(ctx context.Context) { + defer m.wg.Done() + + ticker := time.NewTicker(m.cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + m.logger.Info("cleanup loop stopped (context cancelled)") + return + case <-m.stopCh: + m.logger.Info("cleanup loop stopped") + return + case <-ticker.C: + m.performCleanup(ctx) + } + } +} + +// performCleanup checks for and cleans up stale containers (Docker mode only) +func (m *Manager) performCleanup(ctx context.Context) { + m.logger.Debug("running cleanup check") + + // Skip cleanup if container manager is not available + if m.containerManager == nil { + m.logger.Debug("skipping cleanup - no container manager") + return + } + + // List all kandev-managed containers + containers, err := m.containerManager.ListManagedContainers(ctx) + if err != nil { + m.logger.Error("failed to list containers for cleanup", zap.Error(err)) + return + } + + for _, container := range containers { + if container.State == containerStateExited { + m.cleanupExitedContainer(ctx, container.ID) + } + } +} + +// cleanupExitedContainer handles cleanup for a single exited container. +func (m *Manager) cleanupExitedContainer(ctx context.Context, containerID string) { + execution, tracked := m.executionStore.GetByContainerID(containerID) + if !tracked { + return + } + + // Get container info to get exit code + info, err := m.containerManager.GetContainerInfo(ctx, containerID) + if err != nil { + m.logger.Warn("failed to get container info during cleanup", + zap.String("container_id", containerID), + zap.Error(err)) + return + } + + // Mark execution as completed + errorMsg := "" + if info.ExitCode != 0 { + errorMsg = fmt.Sprintf("container exited with code %d", info.ExitCode) + } + _ = m.MarkCompleted(execution.ID, info.ExitCode, errorMsg) + + // Remove the container + if err := m.containerManager.RemoveContainer(ctx, containerID, false); err != nil { + m.logger.Warn("failed to remove container during cleanup", + zap.String("container_id", containerID), + zap.Error(err)) + } + + // Remove the execution from tracking so new agents can be launched + m.RemoveExecution(execution.ID) +} + +// CleanupStaleExecutionBySessionID removes a stale execution from tracking without stopping it. +// +// A "stale" execution is one where the agent process has stopped externally (crashed, killed, +// or terminated outside of our control) but the execution is still tracked in memory. +// +// When to use this: +// - After detecting the agentctl HTTP server is unreachable +// - When the agent container no longer exists (Docker runtime) +// - After server restart when recovering persisted state +// - When IsAgentRunningForSession returns false but execution exists +// +// This method performs cleanup: +// 1. Closes the agentctl HTTP client connection +// 2. Removes the execution from the in-memory tracking store +// +// What this does NOT do: +// - Stop the agent process (assumed already stopped) +// - Clean up worktrees or containers (caller's responsibility) +// - Update database session state (caller's responsibility) +// +// This is safe to call even if the process is still running - it won't send kill signals. +// Use StopAgent if you need to actively terminate a running agent. +// +// Returns nil if no execution exists for the session (idempotent). +func (m *Manager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return nil // No execution to clean up + } + + m.logger.Info("cleaning up stale agent execution", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID)) + + // Close agentctl connection if it exists + if execution.agentctl != nil { + execution.agentctl.Close() + } + + // Remove from execution store + m.executionStore.Remove(execution.ID) + + return nil +} + +// RemoveExecution removes an execution from tracking. +// +// ⚠️ WARNING: This is a potentially dangerous operation that should only be called when: +// 1. The agent process has been fully stopped (via StopAgent) +// 2. All cleanup operations have completed (worktree cleanup, container removal) +// 3. The execution is in a terminal state (Completed, Failed, or Cancelled) +// +// This method: +// - Removes the execution from the in-memory store +// - Makes the sessionID available for new executions +// - Does NOT stop the agent process (call StopAgent first) +// - Does NOT close the agentctl client (call execution.agentctl.Close() first) +// - Does NOT clean up resources (worktrees, containers, etc.) +// +// After calling this, the executionID and sessionID can no longer be used to query +// or control the execution. Any references to this execution will become invalid. +// +// Typical usage: Called by cleanup loops or after successful StopAgent completion. +// For stale/dead executions, use CleanupStaleExecutionBySessionID instead. +func (m *Manager) RemoveExecution(executionID string) { + m.executionStore.Remove(executionID) + m.logger.Debug("removed execution from tracking", + zap.String("execution_id", executionID)) +} diff --git a/apps/backend/internal/agent/lifecycle/manager_lifecycle_test.go b/apps/backend/internal/agent/lifecycle/manager_lifecycle_test.go new file mode 100644 index 0000000000..1bfdb2dcfa --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_lifecycle_test.go @@ -0,0 +1,127 @@ +package lifecycle + +import ( + "context" + "testing" + "time" + + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestManager_MarkCompleted_Success(t *testing.T) { + mgr := newTestManager() + + execution := &AgentExecution{ + ID: "test-execution-id", + TaskID: "test-task-id", + AgentProfileID: "test-agent", + ContainerID: "container-123", + Status: v1.AgentStatusRunning, + StartedAt: time.Now(), + } + + mgr.executionStore.Add(execution) + + // Mark as completed successfully (exit code 0) + err := mgr.MarkCompleted("test-execution-id", 0, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, _ := mgr.GetExecution("test-execution-id") + if got.Status != v1.AgentStatusCompleted { + t.Errorf("expected status %v, got %v", v1.AgentStatusCompleted, got.Status) + } + if got.FinishedAt == nil { + t.Error("expected FinishedAt to be set") + } + if got.ExitCode == nil || *got.ExitCode != 0 { + t.Errorf("expected exit code 0, got %v", got.ExitCode) + } +} + +func TestManager_MarkCompleted_Failure(t *testing.T) { + mgr := newTestManager() + + execution := &AgentExecution{ + ID: "test-execution-id", + TaskID: "test-task-id", + AgentProfileID: "test-agent", + ContainerID: "container-123", + Status: v1.AgentStatusRunning, + StartedAt: time.Now(), + } + + mgr.executionStore.Add(execution) + + // Mark as failed + err := mgr.MarkCompleted("test-execution-id", 1, "process failed") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, _ := mgr.GetExecution("test-execution-id") + if got.Status != v1.AgentStatusFailed { + t.Errorf("expected status %v, got %v", v1.AgentStatusFailed, got.Status) + } + if got.ErrorMessage != "process failed" { + t.Errorf("expected error message 'process failed', got %q", got.ErrorMessage) + } + if got.ExitCode == nil || *got.ExitCode != 1 { + t.Errorf("expected exit code 1, got %v", got.ExitCode) + } +} + +func TestManager_MarkCompleted_NotFound(t *testing.T) { + mgr := newTestManager() + + err := mgr.MarkCompleted("non-existent", 0, "") + if err == nil { + t.Error("expected error for non-existent execution") + } +} + +func TestManager_RemoveExecution(t *testing.T) { + mgr := newTestManager() + + execution := &AgentExecution{ + ID: "test-execution-id", + TaskID: "test-task-id", + SessionID: "test-session-id", + ContainerID: "container-123", + } + + mgr.executionStore.Add(execution) + + // Remove execution + mgr.RemoveExecution("test-execution-id") + + // Verify it's gone from all maps + if _, found := mgr.GetExecution("test-execution-id"); found { + t.Error("execution should be removed from executions map") + } + if _, found := mgr.GetExecutionBySessionID("test-session-id"); found { + t.Error("execution should be removed from bySession map") + } + + // Remove non-existent should not panic + mgr.RemoveExecution("non-existent") +} + +func TestManager_StartStop(t *testing.T) { + mgr := newTestManager() + + ctx := context.Background() + + // Test Start + err := mgr.Start(ctx) + if err != nil { + t.Fatalf("unexpected error starting manager: %v", err) + } + + // Test Stop + err = mgr.Stop() + if err != nil { + t.Fatalf("unexpected error stopping manager: %v", err) + } +} diff --git a/apps/backend/internal/agent/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/lifecycle/manager_passthrough.go new file mode 100644 index 0000000000..e0da92e21b --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_passthrough.go @@ -0,0 +1,575 @@ +package lifecycle + +import ( + "context" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/runtime" + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/server/process" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/events" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// MarkPassthroughRunning marks a passthrough execution as running when user submits input. +// This is called when Enter key is detected in the terminal handler. +// It updates the execution status and publishes an AgentRunning event. +func (m *Manager) MarkPassthroughRunning(sessionID string) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + if execution.PassthroughProcessID == "" { + return fmt.Errorf("session %s is not in passthrough mode", sessionID) + } + + // Only publish if not already running (prevents duplicate events) + if execution.Status != v1.AgentStatusRunning { + m.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning) + m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentRunning, execution) + } + + return nil +} + +// WritePassthroughStdin writes data to the agent process stdin in passthrough mode. +// Returns an error if the session is not in passthrough mode or if writing fails. +// Note: For terminal handler input, use MarkPassthroughRunning directly since +// the terminal handler writes to PTY directly for performance. +func (m *Manager) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + if execution.PassthroughProcessID == "" { + return fmt.Errorf("session %s is not in passthrough mode", sessionID) + } + + // Get the interactive runner from runtime + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return fmt.Errorf("interactive runner not available") + } + + // Write to stdin + if err := interactiveRunner.WriteStdin(execution.PassthroughProcessID, data); err != nil { + return err + } + + return nil +} + +// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. +func (m *Manager) IsPassthroughSession(ctx context.Context, sessionID string) bool { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return false + } + return execution.PassthroughProcessID != "" +} + +// ResizePassthroughPTY resizes the PTY for a passthrough process. +// Returns an error if the session is not in passthrough mode or if resizing fails. +func (m *Manager) ResizePassthroughPTY(ctx context.Context, sessionID string, cols, rows uint16) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + if execution.PassthroughProcessID == "" { + return fmt.Errorf("session %s is not in passthrough mode", sessionID) + } + + // Get the interactive runner from runtime + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return fmt.Errorf("interactive runner not available") + } + + return interactiveRunner.ResizeBySession(sessionID, cols, rows) +} + +// GetPassthroughBuffer returns the buffered output from the passthrough process. +// This is used for new subscribers to catch up on output. +func (m *Manager) GetPassthroughBuffer(ctx context.Context, sessionID string) (string, error) { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return "", fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + if execution.PassthroughProcessID == "" { + return "", fmt.Errorf("session %s is not in passthrough mode", sessionID) + } + + // Get the interactive runner from runtime + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return "", fmt.Errorf("interactive runner not available") + } + + chunks, ok := interactiveRunner.GetBuffer(execution.PassthroughProcessID) + if !ok { + return "", fmt.Errorf("passthrough process not found") + } + + // Concatenate all chunks into a single string + var buffer strings.Builder + for _, chunk := range chunks { + buffer.WriteString(chunk.Data) + } + + return buffer.String(), nil +} + +// buildPassthroughEnv builds the environment map for a passthrough session, +// including Kandev metadata and required credentials from the agent runtime config. +func (m *Manager) buildPassthroughEnv(ctx context.Context, execution *AgentExecution, requiredEnv []string) map[string]string { + env := make(map[string]string) + env["KANDEV_TASK_ID"] = execution.TaskID + env["KANDEV_SESSION_ID"] = execution.SessionID + env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID + if m.credsMgr != nil { + for _, credKey := range requiredEnv { + if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { + env[credKey] = value + } + } + } + return env +} + +// startPassthroughShell starts the shell session for a passthrough execution. +// Non-fatal errors are logged with the provided warning message. +func (m *Manager) startPassthroughShell(ctx context.Context, execution *AgentExecution, shellWarnMsg string) { + if execution.agentctl == nil { + return + } + if err := execution.agentctl.StartShell(ctx); err != nil { + m.logger.Warn(shellWarnMsg, + zap.String("execution_id", execution.ID), + zap.Error(err)) + } else { + m.logger.Info("shell session started for passthrough mode", + zap.String("execution_id", execution.ID)) + } +} + +// passthroughAgentCommand validates passthrough support and builds the command for a passthrough session. +// Returns the PassthroughAgent, PassthroughConfig, RuntimeConfig pointer, command, and any error. +func (m *Manager) passthroughAgentCommand(execution *AgentExecution, profileInfo *AgentProfileInfo) (agents.PassthroughAgent, agents.PassthroughConfig, *agents.RuntimeConfig, agents.Command, error) { + agentConfig, err := m.getAgentConfigForExecution(execution) + if err != nil { + return nil, agents.PassthroughConfig{}, nil, agents.Command{}, fmt.Errorf("failed to get agent config: %w", err) + } + + ptAgent, ok := agentConfig.(agents.PassthroughAgent) + if !ok { + return nil, agents.PassthroughConfig{}, nil, agents.Command{}, fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) + } + + pt := ptAgent.PassthroughConfig() + rt := agentConfig.Runtime() + taskDescription := getTaskDescriptionFromMetadata(execution) + + cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ + Model: profileModel(profileInfo), + SessionID: execution.ACPSessionID, + Prompt: taskDescription, + PermissionValues: profilePermissionValues(profileInfo), + }) + if cmd.IsEmpty() { + return nil, agents.PassthroughConfig{}, nil, agents.Command{}, fmt.Errorf("passthrough command is empty for agent %s", agentConfig.ID()) + } + return ptAgent, pt, rt, cmd, nil +} + +// startInteractiveProcess launches the interactive PTY process for a passthrough session. +// Returns the process info on success. +func (m *Manager) startInteractiveProcess(ctx context.Context, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command) (*process.InteractiveProcessInfo, error) { + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return nil, fmt.Errorf("interactive runner not available for passthrough mode") + } + + // Some agents (like Codex) require the terminal to be connected first because + // they query the terminal for cursor position on startup. + startReq := process.InteractiveStartRequest{ + SessionID: execution.SessionID, + Command: cmd.Args(), + WorkingDir: execution.WorkspacePath, + Env: env, + PromptPattern: pt.PromptPattern, + IdleTimeout: pt.IdleTimeout, + BufferMaxBytes: pt.BufferMaxBytes, + StatusDetector: pt.StatusDetector, + CheckInterval: pt.CheckInterval, + StabilityWindow: pt.StabilityWindow, + ImmediateStart: !pt.WaitForTerminal, + DefaultCols: 120, + DefaultRows: 40, + } + + processInfo, err := interactiveRunner.Start(ctx, startReq) + if err != nil { + m.updateExecutionError(execution.ID, "failed to start passthrough session: "+err.Error()) + return nil, fmt.Errorf("failed to start passthrough session: %w", err) + } + return processInfo, nil +} + +// startPassthroughSession starts an agent in passthrough mode (direct terminal interaction). +// Instead of using ACP protocol, the agent's stdin/stdout is passed through directly. +func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentExecution, profileInfo *AgentProfileInfo) error { + _, pt, rt, cmd, err := m.passthroughAgentCommand(execution, profileInfo) + if err != nil { + return err + } + + m.logger.Info("passthrough command built", + zap.String("session_id", execution.SessionID), + zap.Strings("full_command", cmd.Args())) + + env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv) + + processInfo, err := m.startInteractiveProcess(ctx, execution, pt, env, cmd) + if err != nil { + return err + } + + execution.PassthroughProcessID = processInfo.ID + + m.logger.Info("passthrough session started", + zap.String("execution_id", execution.ID), + zap.String("task_id", execution.TaskID), + zap.String("session_id", execution.SessionID), + zap.String("process_id", processInfo.ID), + zap.Strings("command", cmd.Args())) + + m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "") + m.startPassthroughShell(ctx, execution, "failed to start shell for passthrough session") + + if m.streamManager != nil && execution.agentctl != nil { + go m.streamManager.connectWorkspaceStream(execution, nil) + } + + return nil +} + +// profileModel extracts the model from profile info, returning empty string if nil. +func profileModel(p *AgentProfileInfo) string { + if p == nil { + return "" + } + return p.Model +} + +// profilePermissionValues builds a permission values map from profile info. +func profilePermissionValues(p *AgentProfileInfo) map[string]bool { + if p == nil { + return nil + } + return map[string]bool{ + "auto_approve": p.AutoApprove, + "dangerously_skip_permissions": p.DangerouslySkipPermissions, + "allow_indexing": p.AllowIndexing, + } +} + +// ResumePassthroughSession restarts a passthrough session after backend restart. +// This is called when user reconnects to a terminal but the PTY process is no longer running. +// If the agent supports resume, it uses the resume flag to continue the last conversation. +// Otherwise, it starts a fresh CLI session with the same profile settings. +func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no execution found for session: %s", sessionID) + } + + // Get agent config + agentConfig, err := m.getAgentConfigForExecution(execution) + if err != nil { + return fmt.Errorf("failed to get agent config: %w", err) + } + + ptAgent, ok := agentConfig.(agents.PassthroughAgent) + if !ok { + return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) + } + + pt := ptAgent.PassthroughConfig() + rt := agentConfig.Runtime() + + // Get profile info for permission settings + var profileInfo *AgentProfileInfo + if m.profileResolver != nil && execution.AgentProfileID != "" { + profileInfo, _ = m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) + } + + // Build the resume command + cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ + Model: profileModel(profileInfo), + Resume: true, + PermissionValues: profilePermissionValues(profileInfo), + }) + if cmd.IsEmpty() { + return fmt.Errorf("passthrough resume command is empty for agent %s", agentConfig.ID()) + } + + m.logger.Info("resuming passthrough session", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID), + zap.Strings("command", cmd.Args())) + + // Get the interactive runner + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return fmt.Errorf("interactive runner not available") + } + + // Build environment variables including required credentials + env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv) + + // Start the interactive process. + // Always use immediate start on resume — the terminal WebSocket is already connected, + // so we don't need to wait for a resize to get exact dimensions. The first resize + // from the terminal will correct the dimensions. Without this, TUI apps that use + // WaitForTerminal would never start because the frontend may not send resizes + // to a process it doesn't know about yet. + startReq := process.InteractiveStartRequest{ + SessionID: sessionID, + Command: cmd.Args(), + WorkingDir: execution.WorkspacePath, + Env: env, + IdleTimeout: pt.IdleTimeout, + BufferMaxBytes: pt.BufferMaxBytes, + StatusDetector: pt.StatusDetector, + CheckInterval: pt.CheckInterval, + StabilityWindow: pt.StabilityWindow, + ImmediateStart: true, + DefaultCols: 120, + DefaultRows: 40, + } + + processInfo, err := interactiveRunner.Start(ctx, startReq) + if err != nil { + return fmt.Errorf("failed to start passthrough session: %w", err) + } + + // Update the execution with new process ID + execution.PassthroughProcessID = processInfo.ID + + m.logger.Info("passthrough session resumed", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID), + zap.String("process_id", processInfo.ID)) + + // Start shell session for workspace shell access (right panel terminal). + // This needs to be done after resume since the shell process was killed on backend restart. + m.startPassthroughShell(ctx, execution, "failed to start shell for resumed passthrough session") + + // Connect to workspace stream for shell/git/file features. + // Only connect if not already connected (process restart reuses the same agentctl). + if m.streamManager != nil && execution.agentctl != nil && execution.GetWorkspaceStream() == nil { + go m.streamManager.connectWorkspaceStream(execution, nil) + } + + return nil +} + +// handlePassthroughTurnComplete is called when turn detection fires for a passthrough session. +// This marks the execution as ready for follow-up prompts when the agent finishes processing. +func (m *Manager) handlePassthroughTurnComplete(sessionID string) { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + m.logger.Debug("turn complete for unknown session (may have ended)", + zap.String("session_id", sessionID)) + return + } + + m.logger.Info("passthrough turn complete", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID)) + + // Mark execution as ready for follow-up prompts + // This publishes AgentReady event to notify subscribers + if err := m.MarkReady(execution.ID); err != nil { + m.logger.Error("failed to mark execution as ready after passthrough turn complete", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } +} + +// handlePassthroughOutput handles output from a passthrough process and publishes it to the event bus. +// This is called when running in standalone mode without a WorkspaceTracker. +func (m *Manager) handlePassthroughOutput(output *agentctltypes.ProcessOutput) { + if output == nil { + return + } + + execution, exists := m.executionStore.GetBySessionID(output.SessionID) + if !exists { + m.logger.Debug("passthrough output for unknown session", + zap.String("session_id", output.SessionID)) + return + } + + // Convert to agentctl client type for event publisher + clientOutput := &agentctl.ProcessOutput{ + SessionID: output.SessionID, + ProcessID: output.ProcessID, + Kind: output.Kind, + Stream: output.Stream, + Data: output.Data, + Timestamp: output.Timestamp, + } + + m.eventPublisher.PublishProcessOutput(execution, clientOutput) +} + +// handlePassthroughStatus handles status updates from a passthrough process and publishes to the event bus. +// This is called when running in standalone mode without a WorkspaceTracker. +// When the process exits while a WebSocket is connected, it attempts auto-restart with rate limiting. +func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpdate) { + if status == nil { + return + } + + execution, exists := m.executionStore.GetBySessionID(status.SessionID) + if !exists { + m.logger.Debug("passthrough status for unknown session", + zap.String("session_id", status.SessionID)) + return + } + + // Convert to agentctl client type for event publisher + clientStatus := &agentctl.ProcessStatusUpdate{ + SessionID: status.SessionID, + ProcessID: status.ProcessID, + Kind: status.Kind, + Command: status.Command, + ScriptName: status.ScriptName, + WorkingDir: status.WorkingDir, + Status: status.Status, + ExitCode: status.ExitCode, + Timestamp: status.Timestamp, + } + + m.eventPublisher.PublishProcessStatus(execution, clientStatus) + + // Check if process exited and should be auto-restarted + // Only restart if this is the ACTUAL passthrough process, not user shell terminals + // Run asynchronously to allow the old process to be cleaned up first + if status.Status == agentctltypes.ProcessStatusExited || status.Status == agentctltypes.ProcessStatusFailed { + // Only trigger auto-restart for the passthrough process, not for user shell terminals + if execution.PassthroughProcessID != "" && status.ProcessID == execution.PassthroughProcessID { + go m.handlePassthroughExit(execution, status) + } else { + m.logger.Debug("process exited but not the passthrough process, skipping auto-restart", + zap.String("session_id", status.SessionID), + zap.String("exited_process_id", status.ProcessID), + zap.String("passthrough_process_id", execution.PassthroughProcessID)) + } + } +} + +// handlePassthroughExit handles auto-restart logic when a passthrough process exits. +// This function is called asynchronously to allow the old process to be cleaned up first. +func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agentctltypes.ProcessStatusUpdate) { + const restartDelay = 500 * time.Millisecond + const cleanupDelay = 100 * time.Millisecond // Wait for old process cleanup + + // Wait a bit for the old process to be cleaned up from the process map + time.Sleep(cleanupDelay) + + sessionID := execution.SessionID + + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + m.logger.Debug("no interactive runner available for auto-restart", + zap.String("session_id", sessionID)) + return + } + + // Check if WebSocket is still connected (use session-level tracking which survives process deletion) + if !interactiveRunner.HasActiveWebSocketBySession(sessionID) { + m.logger.Debug("no active WebSocket, skipping auto-restart", + zap.String("session_id", sessionID)) + return + } + + exitCode := 0 + if status.ExitCode != nil { + exitCode = *status.ExitCode + } + + m.logger.Info("passthrough process exited with active WebSocket, attempting auto-restart", + zap.String("session_id", sessionID), + zap.Int("exit_code", exitCode)) + + // Send restart notification to terminal (use session-level to survive process deletion) + restartMsg := "\r\n\x1b[33m[Agent exited. Restarting...]\x1b[0m\r\n" + if err := interactiveRunner.WriteToDirectOutputBySession(sessionID, []byte(restartMsg)); err != nil { + m.logger.Debug("failed to write restart message to terminal", + zap.String("session_id", sessionID), + zap.Error(err)) + } + + // Delay before restart + time.Sleep(restartDelay) + + // Check WebSocket is still connected after delay (use session-level tracking) + if !interactiveRunner.HasActiveWebSocketBySession(sessionID) { + m.logger.Debug("WebSocket disconnected during restart delay, aborting", + zap.String("session_id", sessionID)) + return + } + + // Attempt restart + ctx := context.Background() + if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { + m.logger.Error("failed to auto-restart passthrough session", + zap.String("session_id", sessionID), + zap.Error(err)) + + // Send error message to terminal + errorMsg := fmt.Sprintf("\r\n\x1b[31m[Restart failed: %s]\x1b[0m\r\n", err.Error()) + if writeErr := interactiveRunner.WriteToDirectOutputBySession(sessionID, []byte(errorMsg)); writeErr != nil { + m.logger.Debug("failed to write restart error message to terminal", + zap.String("session_id", sessionID), + zap.Error(writeErr)) + } + return + } + + // Connect the session's existing WebSocket to the new process + if interactiveRunner.ConnectSessionWebSocket(execution.PassthroughProcessID) { + m.logger.Info("passthrough session auto-restarted and reconnected WebSocket", + zap.String("session_id", sessionID), + zap.String("new_process_id", execution.PassthroughProcessID)) + } else { + m.logger.Warn("passthrough session restarted but failed to reconnect WebSocket", + zap.String("session_id", sessionID), + zap.String("new_process_id", execution.PassthroughProcessID)) + } +} + +// GetInteractiveRunner returns the interactive runner for passthrough mode. +// Returns nil if the runtime is not available or doesn't support passthrough. +func (m *Manager) GetInteractiveRunner() *process.InteractiveRunner { + if m.runtimeRegistry == nil { + return nil + } + standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone) + if err != nil { + return nil + } + return standaloneRT.GetInteractiveRunner() +} diff --git a/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go b/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go new file mode 100644 index 0000000000..bb3c00053b --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go @@ -0,0 +1,224 @@ +package lifecycle + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" +) + +// mockPassthroughProfileResolver is a mock for testing passthrough verification +type mockPassthroughProfileResolver struct { + cliPassthrough bool + err error +} + +func (m *mockPassthroughProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { + if m.err != nil { + return nil, m.err + } + return &AgentProfileInfo{ + ProfileID: profileID, + CLIPassthrough: m.cliPassthrough, + }, nil +} + +func TestBuildPassthroughCommand(t *testing.T) { + tests := []struct { + name string + agent agents.PassthroughAgent + opts agents.PassthroughOptions + wantCmd []string + }{ + { + name: "basic command without options", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli", "--verbose")}, + }, + }, + opts: agents.PassthroughOptions{Resume: true}, + wantCmd: []string{"test-cli", "--verbose"}, + }, + { + name: "command with model", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ModelFlag: agents.NewParam("--model", "{model}")}, + }, + }, + opts: agents.PassthroughOptions{Model: "gpt-4"}, + wantCmd: []string{"test-cli", "--model", "gpt-4"}, + }, + { + name: "resume with single-word flag", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ResumeFlag: agents.NewParam("-c")}, + }, + }, + opts: agents.PassthroughOptions{Resume: true}, + wantCmd: []string{"test-cli", "-c"}, + }, + { + name: "resume with multi-word flag", + agent: &testAgent{ + id: "gemini-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("gemini"), ResumeFlag: agents.NewParam("--resume", "latest")}, + }, + }, + opts: agents.PassthroughOptions{Resume: true}, + wantCmd: []string{"gemini", "--resume", "latest"}, + }, + { + name: "permission settings as CLI flags", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, + PermSettings: map[string]agents.PermissionSetting{ + "auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--yes"}, + }, + }, + }, + opts: agents.PassthroughOptions{ + PermissionValues: map[string]bool{"auto_approve": true}, + }, + wantCmd: []string{"test-cli", "--yes"}, + }, + { + name: "full resume with model + settings + resume flag", + agent: &testAgent{ + id: "claude-code", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("npx", "-y", "@anthropic-ai/claude-code"), + ModelFlag: agents.NewParam("--model", "{model}"), + ResumeFlag: agents.NewParam("-c"), + }, + PermSettings: map[string]agents.PermissionSetting{ + "dangerously_skip_permissions": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions"}, + }, + }, + }, + opts: agents.PassthroughOptions{ + Model: "claude-sonnet-4", + Resume: true, + PermissionValues: map[string]bool{"dangerously_skip_permissions": true}, + }, + wantCmd: []string{"npx", "-y", "@anthropic-ai/claude-code", "--model", "claude-sonnet-4", "--dangerously-skip-permissions", "-c"}, + }, + { + name: "permission setting with cli_flag_value", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, + PermSettings: map[string]agents.PermissionSetting{ + "auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--approve-level", CLIFlagValue: "all"}, + }, + }, + }, + opts: agents.PassthroughOptions{ + PermissionValues: map[string]bool{"auto_approve": true}, + }, + wantCmd: []string{"test-cli", "--approve-level", "all"}, + }, + { + name: "new session with prompt (positional)", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, + }, + }, + opts: agents.PassthroughOptions{Prompt: "fix the bug"}, + wantCmd: []string{"test-cli", "fix the bug"}, + }, + { + name: "new session with prompt flag", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), PromptFlag: agents.NewParam("--prompt", "{prompt}")}, + }, + }, + opts: agents.PassthroughOptions{Prompt: "fix the bug"}, + wantCmd: []string{"test-cli", "--prompt", "fix the bug"}, + }, + { + name: "resume with session ID", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("test-cli"), + SessionResumeFlag: agents.NewParam("--resume"), + }, + }, + }, + opts: agents.PassthroughOptions{SessionID: "sess-123"}, + wantCmd: []string{"test-cli", "--resume", "sess-123"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.agent.BuildPassthroughCommand(tt.opts).Args() + + if len(got) != len(tt.wantCmd) { + t.Errorf("BuildPassthroughCommand() = %v, want %v", got, tt.wantCmd) + return + } + + for i, arg := range got { + if arg != tt.wantCmd[i] { + t.Errorf("BuildPassthroughCommand()[%d] = %q, want %q", i, arg, tt.wantCmd[i]) + } + } + }) + } +} + +func TestManager_VerifyPassthroughEnabled(t *testing.T) { + tests := []struct { + name string + profileID string + wantErr bool + }{ + { + name: "valid profile with passthrough enabled", + profileID: "test-profile", + wantErr: false, + }, + { + name: "empty profile ID", + profileID: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr := newTestManager() + + // Override profile resolver for this test + if tt.profileID != "" { + mgr.profileResolver = &mockPassthroughProfileResolver{ + cliPassthrough: true, + } + } + + err := mgr.verifyPassthroughEnabled(context.Background(), "test-session", tt.profileID) + if (err != nil) != tt.wantErr { + t.Errorf("verifyPassthroughEnabled() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/apps/backend/internal/agent/lifecycle/manager_profile.go b/apps/backend/internal/agent/lifecycle/manager_profile.go new file mode 100644 index 0000000000..9915c42471 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_profile.go @@ -0,0 +1,150 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/agent/runtime" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" +) + +// ResolveAgentProfile resolves an agent profile ID to profile information. +// This is exposed for external callers (like the orchestrator executor) to get profile info. +// The profile's model is guaranteed to be non-empty as it's validated at creation time. +func (m *Manager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { + if m.profileResolver == nil { + return nil, fmt.Errorf("profile resolver not configured") + } + return m.profileResolver.ResolveProfile(ctx, profileID) +} + +// getAgentConfigForExecution retrieves the agent configuration for an execution. +// The execution must have AgentCommand set (which includes the agent type). +func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (agents.Agent, error) { + if execution.AgentProfileID == "" { + return nil, fmt.Errorf("execution %s has no agent profile ID", execution.ID) + } + + if m.profileResolver == nil { + return nil, fmt.Errorf("profile resolver not configured") + } + + profileInfo, err := m.profileResolver.ResolveProfile(context.Background(), execution.AgentProfileID) + if err != nil { + return nil, fmt.Errorf("failed to resolve profile: %w", err) + } + + agentTypeName := profileInfo.AgentName + agentConfig, ok := m.registry.Get(agentTypeName) + if !ok { + return nil, fmt.Errorf("agent type not found: %s", agentTypeName) + } + + return agentConfig, nil +} + +// resolveMcpServers centralizes MCP resolution for a session: +// - loads per-agent MCP config, +// - applies executor-scoped transport rules, allow/deny lists, URL rewrites, and env injection, +// - converts to ACP stdio server definitions used during session initialization. +func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) { + if execution == nil { + return nil, nil + } + return m.resolveMcpServersWithParams(ctx, execution.AgentProfileID, execution.Metadata, agentConfig) +} + +// applyExecutorMcpPolicy reads executor metadata and applies any executor-scoped MCP policy +// override on top of the given default policy. It returns the updated policy and executor ID. +func (m *Manager) applyExecutorMcpPolicy(profileID string, executorID string, metadata map[string]interface{}, policy mcpconfig.Policy) (mcpconfig.Policy, string, error) { + if metadata == nil { + return policy, executorID, nil + } + if value, ok := metadata["executor_id"].(string); ok { + executorID = value + } + value, ok := metadata["executor_mcp_policy"] + if !ok { + return policy, executorID, nil + } + updated, policyWarnings, err := mcpconfig.ApplyExecutorPolicy(policy, value) + if err != nil { + return policy, executorID, fmt.Errorf("invalid executor MCP policy: %w", err) + } + policy = updated + for _, warning := range policyWarnings { + m.logger.Warn("mcp policy warning", + zap.String("profile_id", profileID), + zap.String("executor_id", executorID), + zap.String("warning", warning)) + } + return policy, executorID, nil +} + +// resolveMcpServersWithParams resolves MCP servers with explicit parameters. +// This is used by Launch() before the execution object exists. +func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID string, metadata map[string]interface{}, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) { + if m.mcpProvider == nil || agentConfig == nil { + return nil, nil + } + + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return nil, nil + } + + config, err := m.mcpProvider.GetConfigByProfileID(ctx, profileID) + if err != nil { + if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) || errors.Is(err, mcpconfig.ErrAgentProfileNotFound) { + return nil, nil + } + m.logger.Warn("failed to load MCP config", + zap.String("profile_id", profileID), + zap.Error(err)) + return nil, err + } + if config == nil || !config.Enabled { + return nil, nil + } + + // Get default runtime for MCP policy (used before execution exists) + defaultRT, _ := m.getDefaultRuntime() + policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(defaultRT)) + executorID := "" + + policy, executorID, err = m.applyExecutorMcpPolicy(profileID, executorID, metadata, policy) + if err != nil { + return nil, err + } + + resolved, warnings, err := mcpconfig.Resolve(config, policy) + if err != nil { + return nil, err + } + for _, warning := range warnings { + m.logger.Warn("mcp config warning", + zap.String("profile_id", profileID), + zap.String("executor_id", executorID), + zap.String("warning", warning)) + } + + return mcpconfig.ToACPServers(resolved), nil +} + +func runtimeName(rt Runtime) runtime.Name { + if rt == nil { + return runtime.NameUnknown + } + return rt.Name() +} + +// initializeACPSession delegates to SessionManager for full ACP session initialization and prompting +func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, mcpServers []agentctltypes.McpServer) error { + return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, mcpServers, m.MarkReady) +} diff --git a/apps/backend/internal/agent/lifecycle/manager_runtime.go b/apps/backend/internal/agent/lifecycle/manager_runtime.go new file mode 100644 index 0000000000..0b5c244bc0 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_runtime.go @@ -0,0 +1,58 @@ +package lifecycle + +import ( + "fmt" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/task/models" +) + +// getRuntimeForExecutorType returns the appropriate runtime for the given executor type. +// If the executor type is empty or the runtime is not available, behavior depends on runtimeFallbackPolicy. +func (m *Manager) getRuntimeForExecutorType(executorType string) (Runtime, error) { + if m.runtimeRegistry == nil { + return nil, fmt.Errorf("no runtime registry configured") + } + + if executorType != "" { + runtimeName := runtime.ExecutorTypeToRuntime(models.ExecutorType(executorType)) + rt, err := m.runtimeRegistry.GetRuntime(runtimeName) + if err == nil { + return rt, nil + } + + // Handle fallback based on policy + switch m.runtimeFallbackPolicy { + case RuntimeFallbackDeny: + return nil, fmt.Errorf("runtime %s not available and fallback is denied: %w", runtimeName, err) + case RuntimeFallbackWarn: + m.logger.Warn("requested runtime not available, falling back to default", + zap.String("executor_type", executorType), + zap.String("runtime", string(runtimeName)), + zap.Error(err)) + case RuntimeFallbackAllow: + m.logger.Debug("requested runtime not available, falling back to default", + zap.String("executor_type", executorType), + zap.String("runtime", string(runtimeName))) + default: + // Default to warn behavior for backwards compatibility + m.logger.Warn("requested runtime not available, falling back to default", + zap.String("executor_type", executorType), + zap.String("runtime", string(runtimeName)), + zap.Error(err)) + } + } + + return m.runtimeRegistry.GetRuntime(runtime.NameStandalone) +} + +// getDefaultRuntime returns the default runtime (standalone). +// This is used when no executor type is specified. +func (m *Manager) getDefaultRuntime() (Runtime, error) { + if m.runtimeRegistry == nil { + return nil, fmt.Errorf("no runtime registry configured") + } + return m.runtimeRegistry.GetRuntime(runtime.NameStandalone) +} diff --git a/apps/backend/internal/agent/lifecycle/manager_startup.go b/apps/backend/internal/agent/lifecycle/manager_startup.go new file mode 100644 index 0000000000..efa6243ce0 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_startup.go @@ -0,0 +1,256 @@ +package lifecycle + +import ( + "context" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/appctx" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +// StartAgentProcess configures and starts the agent subprocess for an execution. +// This must be called after Launch() to actually start the agent (e.g., auggie, codex). +// The command is built internally based on the execution's agent profile. +func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) error { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return fmt.Errorf("execution %q not found", executionID) + } + + // Check if this execution should use passthrough mode + if execution.AgentProfileID != "" && m.profileResolver != nil { + profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) + if err == nil && profileInfo.CLIPassthrough { + return m.startPassthroughSession(ctx, execution, profileInfo) + } + } + + if execution.agentctl == nil { + return fmt.Errorf("execution %q has no agentctl client", executionID) + } + if execution.AgentCommand == "" { + return fmt.Errorf("execution %q has no agent command configured", executionID) + } + + // Wait for agentctl to be ready + if err := execution.agentctl.WaitForReady(ctx, 60*time.Second); err != nil { + m.updateExecutionError(executionID, "agentctl not ready: "+err.Error()) + return fmt.Errorf("agentctl not ready: %w", err) + } + + taskDescription := getTaskDescriptionFromMetadata(execution) + + m.logger.Warn("StartAgentProcess: task description resolved", + zap.String("execution_id", executionID), + zap.String("task_id", execution.TaskID), + zap.Int("task_description_length", len(taskDescription)), + zap.String("agent_command", execution.AgentCommand), + zap.String("acp_session_id", execution.ACPSessionID)) + + approvalPolicy, agentDisplayName := m.resolveApprovalPolicyAndDisplayName(ctx, execution) + + bootCommand, err := m.configureAndStartAgent(ctx, execution, taskDescription, approvalPolicy) + if err != nil { + return err + } + + m.logger.Info("agent process started", + zap.String("execution_id", executionID), + zap.String("task_id", execution.TaskID), + zap.String("command", bootCommand)) + + return m.initializeAgentSession(ctx, execution, bootCommand, agentDisplayName, taskDescription) +} + +// pollAgentStderr polls the agent's stderr buffer every 2 seconds and updates the boot message. +func (m *Manager) pollAgentStderr(client *agentctl.Client, msg *models.Message, stopCh chan struct{}) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + var lastLineCount int + + for { + select { + case <-stopCh: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + lines, err := client.GetAgentStderr(ctx) + cancel() + if err != nil { + m.logger.Debug("failed to poll agent stderr", zap.Error(err)) + continue + } + + if len(lines) > lastLineCount { + lastLineCount = len(lines) + msg.Content = strings.Join(lines, "\n") + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + if updateErr := m.bootMessageService.UpdateMessage(ctx2, msg); updateErr != nil { + m.logger.Debug("failed to update boot message with stderr", + zap.String("message_id", msg.ID), + zap.Error(updateErr)) + } + cancel2() + } + } + } +} + +// finalizeBootMessage stops the polling goroutine and updates the boot message with final status. +func (m *Manager) finalizeBootMessage(msg *models.Message, stopCh chan struct{}, client *agentctl.Client, status string) { + if msg == nil || m.bootMessageService == nil { + return + } + + // Stop the polling goroutine + if stopCh != nil { + close(stopCh) + } + + // Final stderr fetch + if client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + lines, err := client.GetAgentStderr(ctx) + cancel() + if err == nil && len(lines) > 0 { + msg.Content = strings.Join(lines, "\n") + } + } + + msg.Metadata["status"] = status + msg.Metadata["completed_at"] = time.Now().UTC().Format(time.RFC3339Nano) + if status == containerStateExited { + msg.Metadata["exit_code"] = 0 + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if updateErr := m.bootMessageService.UpdateMessage(ctx, msg); updateErr != nil { + m.logger.Warn("failed to update boot message with final status", + zap.String("message_id", msg.ID), + zap.Error(updateErr)) + } +} + +// buildEnvForRuntime builds environment variables for any runtime. +// This is the unified method used by the runtime interface. +func (m *Manager) buildEnvForRuntime(executionID string, req *LaunchRequest, agentConfig agents.Agent) map[string]string { + env := make(map[string]string) + + // Copy request environment + for k, v := range req.Env { + env[k] = v + } + + // Add standard variables for recovery after backend restart + env["KANDEV_INSTANCE_ID"] = executionID + env["KANDEV_TASK_ID"] = req.TaskID + env["KANDEV_SESSION_ID"] = req.SessionID + env["KANDEV_AGENT_PROFILE_ID"] = req.AgentProfileID + env["TASK_DESCRIPTION"] = req.TaskDescription + + // Add required credentials from agent config + if m.credsMgr != nil && agentConfig != nil { + ctx := context.Background() + for _, credKey := range agentConfig.Runtime().RequiredEnv { + if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { + env[credKey] = value + } + } + } + + return env +} + +// getOrCreateWorktree creates a new worktree or reuses an existing one for session resumption. +// If worktree_id is in metadata, it tries to reuse that specific worktree. +// Otherwise, creates a new worktree with a unique random suffix. +func (m *Manager) getOrCreateWorktree(ctx context.Context, req *LaunchRequest) (*worktree.Worktree, error) { + // Check if we have a worktree_id in metadata for session resumption + var worktreeID string + if req.Metadata != nil { + if id, ok := req.Metadata["worktree_id"].(string); ok && id != "" { + worktreeID = id + } + } + + // Create request with optional WorktreeID for resumption + createReq := worktree.CreateRequest{ + TaskID: req.TaskID, + SessionID: req.SessionID, + TaskTitle: req.TaskTitle, + RepositoryID: req.RepositoryID, + RepositoryPath: req.RepositoryPath, + BaseBranch: req.BaseBranch, + WorktreeBranchPrefix: req.WorktreeBranchPrefix, + PullBeforeWorktree: req.PullBeforeWorktree, + WorktreeID: worktreeID, // If set, will try to reuse this worktree + } + + wt, err := m.worktreeMgr.Create(ctx, createReq) + if err != nil { + return nil, fmt.Errorf("failed to create worktree: %w", err) + } + + if worktreeID != "" && wt.ID == worktreeID { + m.logger.Debug("reusing existing worktree for session resumption", + zap.String("task_id", req.TaskID), + zap.String("worktree_id", wt.ID), + zap.String("worktree_path", wt.Path), + zap.String("branch", wt.Branch)) + } else { + m.logger.Info("created new worktree for task", + zap.String("task_id", req.TaskID), + zap.String("worktree_id", wt.ID), + zap.String("worktree_path", wt.Path), + zap.String("branch", wt.Branch)) + } + + return wt, nil +} + +// waitForAgentctlReady waits for the agentctl HTTP server to be ready. +// This enables shell/workspace features without starting the agent process. +func (m *Manager) waitForAgentctlReady(execution *AgentExecution) { + opStart := time.Now() + // Use detached context that respects stopCh for graceful shutdown + ctx, cancel := appctx.Detached(context.Background(), m.stopCh, 60*time.Second) + defer cancel() + + m.logger.Debug("waiting for agentctl to be ready", + zap.String("execution_id", execution.ID), + zap.String("url", execution.agentctl.BaseURL())) + + if err := execution.agentctl.WaitForReady(ctx, 60*time.Second); err != nil { + m.logger.Error("agentctl not ready", + zap.String("execution_id", execution.ID), + zap.Duration("duration", time.Since(opStart)), + zap.Error(err)) + m.updateExecutionError(execution.ID, "agentctl not ready: "+err.Error()) + // Use the timeout context for event publishing instead of a fresh Background context + m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlError, execution, err.Error()) + return + } + + elapsed := time.Since(opStart) + if elapsed > 10*time.Second { + m.logger.Warn("agentctl ready took longer than expected", + zap.String("execution_id", execution.ID), + zap.Duration("duration", elapsed)) + } else { + m.logger.Debug("agentctl ready - shell/workspace access available", + zap.String("execution_id", execution.ID), + zap.Duration("duration", elapsed)) + } + // Use the timeout context for event publishing instead of a fresh Background context + m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "") +} diff --git a/apps/backend/internal/agent/lifecycle/manager_streaming.go b/apps/backend/internal/agent/lifecycle/manager_streaming.go new file mode 100644 index 0000000000..d522c9e69a --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_streaming.go @@ -0,0 +1,196 @@ +package lifecycle + +import ( + "strings" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// publishStreamingMessage publishes a streaming message event for real-time text updates. +// It creates a new message on first call (currentMessageID empty) or appends to existing. +// The message ID is generated and set synchronously to avoid race conditions. +func (m *Manager) publishStreamingMessage(execution *AgentExecution, content string) { + execution.messageMu.Lock() + isAppend := execution.currentMessageID != "" + messageID := execution.currentMessageID + + // If this is the first chunk of a new message segment, generate the ID now + if !isAppend { + messageID = uuid.New().String() + execution.currentMessageID = messageID + } + execution.messageMu.Unlock() + + event := AgentStreamEventData{ + Type: "message_streaming", + Text: content, + MessageID: messageID, + IsAppend: isAppend, + } + + // Create payload manually to include streaming-specific fields + payload := &AgentStreamEventPayload{ + Type: "agent/event", + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + AgentID: execution.ID, + TaskID: execution.TaskID, + SessionID: execution.SessionID, + Data: &event, + } + + m.logger.Debug("publishing streaming message", + zap.String("execution_id", execution.ID), + zap.String("message_id", messageID), + zap.Bool("is_append", isAppend), + zap.Int("content_length", len(content))) + + // Publish the streaming event - orchestrator will handle create/append logic + m.eventPublisher.PublishAgentStreamEventPayload(payload) +} + +// flushMessageBuffer extracts any accumulated message from the buffer and returns it. +// This is called when a tool use starts or on complete to get the agent's response. +// It also clears the currentMessageID to start fresh for the next message segment. +// Additionally flushes any accumulated thinking content. +func (m *Manager) flushMessageBuffer(execution *AgentExecution) string { + execution.messageMu.Lock() + agentMessage := execution.messageBuffer.String() + thinkingContent := execution.thinkingBuffer.String() + execution.messageBuffer.Reset() + execution.thinkingBuffer.Reset() + // Clear the streaming message IDs so next segment starts fresh + currentMsgID := execution.currentMessageID + currentThinkingID := execution.currentThinkingID + execution.currentMessageID = "" + execution.currentThinkingID = "" + execution.messageMu.Unlock() + + // If we have remaining thinking content, publish it + trimmedThinking := strings.TrimSpace(thinkingContent) + if trimmedThinking != "" { + if currentThinkingID != "" { + // Append to existing streaming thinking message + m.publishStreamingThinkingFinal(execution, currentThinkingID, trimmedThinking) + } else { + // No streaming thinking message exists yet - create one with all the content + // This happens when thinking content has no newlines (never triggered streaming) + m.publishStreamingThinking(execution, trimmedThinking) + } + } + + // If we have remaining message content, publish it + trimmedMessage := strings.TrimSpace(agentMessage) + if trimmedMessage != "" { + if currentMsgID != "" { + // Publish final append to the streaming message + m.publishStreamingMessageFinal(execution, currentMsgID, trimmedMessage) + } else { + // No streaming message exists yet - create one with all the content + // This happens when message content has no newlines (never triggered streaming) + m.publishStreamingMessage(execution, trimmedMessage) + } + // Return empty since we've already handled it via streaming + return "" + } + + return "" +} + +// publishStreamingMessageFinal publishes the final chunk of a streaming message. +// This is called during flush to append any remaining buffered content. +func (m *Manager) publishStreamingMessageFinal(execution *AgentExecution, messageID, content string) { + event := AgentStreamEventData{ + Type: "message_streaming", + Text: content, + MessageID: messageID, + IsAppend: true, + } + + payload := &AgentStreamEventPayload{ + Type: "agent/event", + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + AgentID: execution.ID, + TaskID: execution.TaskID, + SessionID: execution.SessionID, + Data: &event, + } + + m.logger.Debug("publishing final streaming message chunk", + zap.String("execution_id", execution.ID), + zap.String("message_id", messageID), + zap.Int("content_length", len(content))) + + m.eventPublisher.PublishAgentStreamEventPayload(payload) +} + +// publishStreamingThinking publishes a streaming thinking event for real-time thinking updates. +// It creates a new thinking message on first call (currentThinkingID empty) or appends to existing. +// The message ID is generated and set synchronously to avoid race conditions. +func (m *Manager) publishStreamingThinking(execution *AgentExecution, content string) { + execution.messageMu.Lock() + isAppend := execution.currentThinkingID != "" + thinkingID := execution.currentThinkingID + + // If this is the first chunk of a new thinking segment, generate the ID now + if !isAppend { + thinkingID = uuid.New().String() + execution.currentThinkingID = thinkingID + } + execution.messageMu.Unlock() + + event := AgentStreamEventData{ + Type: "thinking_streaming", + Text: content, + MessageID: thinkingID, + IsAppend: isAppend, + MessageType: "thinking", + } + + // Create payload manually to include streaming-specific fields + payload := &AgentStreamEventPayload{ + Type: "agent/event", + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + AgentID: execution.ID, + TaskID: execution.TaskID, + SessionID: execution.SessionID, + Data: &event, + } + + // Publish the streaming event - orchestrator will handle create/append logic + m.eventPublisher.PublishAgentStreamEventPayload(payload) +} + +// publishStreamingThinkingFinal publishes the final chunk of a streaming thinking message. +// This is called during flush to append any remaining buffered thinking content. +func (m *Manager) publishStreamingThinkingFinal(execution *AgentExecution, thinkingID, content string) { + event := AgentStreamEventData{ + Type: "thinking_streaming", + Text: content, + MessageID: thinkingID, + IsAppend: true, + MessageType: "thinking", + } + + payload := &AgentStreamEventPayload{ + Type: "agent/event", + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + AgentID: execution.ID, + TaskID: execution.TaskID, + SessionID: execution.SessionID, + Data: &event, + } + + m.logger.Debug("publishing final streaming thinking chunk", + zap.String("execution_id", execution.ID), + zap.String("thinking_id", thinkingID), + zap.Int("content_length", len(content))) + + m.eventPublisher.PublishAgentStreamEventPayload(payload) +} + +// updateExecutionError updates an execution with an error +func (m *Manager) updateExecutionError(executionID, errorMsg string) { + m.executionStore.UpdateError(executionID, errorMsg) +} diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index b136453419..334ac85115 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -4,17 +4,14 @@ import ( "context" "io" "strings" - "sync" "testing" "time" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/registry" - agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" - "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -332,1097 +329,3 @@ func TestManager_UpdateStatus(t *testing.T) { t.Error("expected error for non-existent execution") } } - -func TestManager_MarkCompleted_Success(t *testing.T) { - mgr := newTestManager() - - execution := &AgentExecution{ - ID: "test-execution-id", - TaskID: "test-task-id", - AgentProfileID: "test-agent", - ContainerID: "container-123", - Status: v1.AgentStatusRunning, - StartedAt: time.Now(), - } - - mgr.executionStore.Add(execution) - - // Mark as completed successfully (exit code 0) - err := mgr.MarkCompleted("test-execution-id", 0, "") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - got, _ := mgr.GetExecution("test-execution-id") - if got.Status != v1.AgentStatusCompleted { - t.Errorf("expected status %v, got %v", v1.AgentStatusCompleted, got.Status) - } - if got.FinishedAt == nil { - t.Error("expected FinishedAt to be set") - } - if got.ExitCode == nil || *got.ExitCode != 0 { - t.Errorf("expected exit code 0, got %v", got.ExitCode) - } -} - -func TestManager_MarkCompleted_Failure(t *testing.T) { - mgr := newTestManager() - - execution := &AgentExecution{ - ID: "test-execution-id", - TaskID: "test-task-id", - AgentProfileID: "test-agent", - ContainerID: "container-123", - Status: v1.AgentStatusRunning, - StartedAt: time.Now(), - } - - mgr.executionStore.Add(execution) - - // Mark as failed - err := mgr.MarkCompleted("test-execution-id", 1, "process failed") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - got, _ := mgr.GetExecution("test-execution-id") - if got.Status != v1.AgentStatusFailed { - t.Errorf("expected status %v, got %v", v1.AgentStatusFailed, got.Status) - } - if got.ErrorMessage != "process failed" { - t.Errorf("expected error message 'process failed', got %q", got.ErrorMessage) - } - if got.ExitCode == nil || *got.ExitCode != 1 { - t.Errorf("expected exit code 1, got %v", got.ExitCode) - } -} - -func TestManager_MarkCompleted_NotFound(t *testing.T) { - mgr := newTestManager() - - err := mgr.MarkCompleted("non-existent", 0, "") - if err == nil { - t.Error("expected error for non-existent execution") - } -} - -func TestManager_RemoveExecution(t *testing.T) { - mgr := newTestManager() - - execution := &AgentExecution{ - ID: "test-execution-id", - TaskID: "test-task-id", - SessionID: "test-session-id", - ContainerID: "container-123", - } - - mgr.executionStore.Add(execution) - - // Remove execution - mgr.RemoveExecution("test-execution-id") - - // Verify it's gone from all maps - if _, found := mgr.GetExecution("test-execution-id"); found { - t.Error("execution should be removed from executions map") - } - if _, found := mgr.GetExecutionBySessionID("test-session-id"); found { - t.Error("execution should be removed from bySession map") - } - - // Remove non-existent should not panic - mgr.RemoveExecution("non-existent") -} - -func TestManager_StartStop(t *testing.T) { - mgr := newTestManager() - - ctx := context.Background() - - // Test Start - err := mgr.Start(ctx) - if err != nil { - t.Fatalf("unexpected error starting manager: %v", err) - } - - // Test Stop - err = mgr.Stop() - if err != nil { - t.Fatalf("unexpected error stopping manager: %v", err) - } -} - -func TestBuildPassthroughCommand(t *testing.T) { - tests := []struct { - name string - agent agents.PassthroughAgent - opts agents.PassthroughOptions - wantCmd []string - }{ - { - name: "basic command without options", - agent: &testAgent{ - id: "test-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli", "--verbose")}, - }, - }, - opts: agents.PassthroughOptions{Resume: true}, - wantCmd: []string{"test-cli", "--verbose"}, - }, - { - name: "command with model", - agent: &testAgent{ - id: "test-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ModelFlag: agents.NewParam("--model", "{model}")}, - }, - }, - opts: agents.PassthroughOptions{Model: "gpt-4"}, - wantCmd: []string{"test-cli", "--model", "gpt-4"}, - }, - { - name: "resume with single-word flag", - agent: &testAgent{ - id: "test-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ResumeFlag: agents.NewParam("-c")}, - }, - }, - opts: agents.PassthroughOptions{Resume: true}, - wantCmd: []string{"test-cli", "-c"}, - }, - { - name: "resume with multi-word flag", - agent: &testAgent{ - id: "gemini-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("gemini"), ResumeFlag: agents.NewParam("--resume", "latest")}, - }, - }, - opts: agents.PassthroughOptions{Resume: true}, - wantCmd: []string{"gemini", "--resume", "latest"}, - }, - { - name: "permission settings as CLI flags", - agent: &testAgent{ - id: "test-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, - PermSettings: map[string]agents.PermissionSetting{ - "auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--yes"}, - }, - }, - }, - opts: agents.PassthroughOptions{ - PermissionValues: map[string]bool{"auto_approve": true}, - }, - wantCmd: []string{"test-cli", "--yes"}, - }, - { - name: "full resume with model + settings + resume flag", - agent: &testAgent{ - id: "claude-code", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{ - Supported: true, - PassthroughCmd: agents.NewCommand("npx", "-y", "@anthropic-ai/claude-code"), - ModelFlag: agents.NewParam("--model", "{model}"), - ResumeFlag: agents.NewParam("-c"), - }, - PermSettings: map[string]agents.PermissionSetting{ - "dangerously_skip_permissions": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions"}, - }, - }, - }, - opts: agents.PassthroughOptions{ - Model: "claude-sonnet-4", - Resume: true, - PermissionValues: map[string]bool{"dangerously_skip_permissions": true}, - }, - wantCmd: []string{"npx", "-y", "@anthropic-ai/claude-code", "--model", "claude-sonnet-4", "--dangerously-skip-permissions", "-c"}, - }, - { - name: "permission setting with cli_flag_value", - agent: &testAgent{ - id: "test-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, - PermSettings: map[string]agents.PermissionSetting{ - "auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--approve-level", CLIFlagValue: "all"}, - }, - }, - }, - opts: agents.PassthroughOptions{ - PermissionValues: map[string]bool{"auto_approve": true}, - }, - wantCmd: []string{"test-cli", "--approve-level", "all"}, - }, - { - name: "new session with prompt (positional)", - agent: &testAgent{ - id: "test-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, - }, - }, - opts: agents.PassthroughOptions{Prompt: "fix the bug"}, - wantCmd: []string{"test-cli", "fix the bug"}, - }, - { - name: "new session with prompt flag", - agent: &testAgent{ - id: "test-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), PromptFlag: agents.NewParam("--prompt", "{prompt}")}, - }, - }, - opts: agents.PassthroughOptions{Prompt: "fix the bug"}, - wantCmd: []string{"test-cli", "--prompt", "fix the bug"}, - }, - { - name: "resume with session ID", - agent: &testAgent{ - id: "test-agent", - StandardPassthrough: agents.StandardPassthrough{ - Cfg: agents.PassthroughConfig{ - Supported: true, - PassthroughCmd: agents.NewCommand("test-cli"), - SessionResumeFlag: agents.NewParam("--resume"), - }, - }, - }, - opts: agents.PassthroughOptions{SessionID: "sess-123"}, - wantCmd: []string{"test-cli", "--resume", "sess-123"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.agent.BuildPassthroughCommand(tt.opts).Args() - - if len(got) != len(tt.wantCmd) { - t.Errorf("BuildPassthroughCommand() = %v, want %v", got, tt.wantCmd) - return - } - - for i, arg := range got { - if arg != tt.wantCmd[i] { - t.Errorf("BuildPassthroughCommand()[%d] = %q, want %q", i, arg, tt.wantCmd[i]) - } - } - }) - } -} - -func TestManager_VerifyPassthroughEnabled(t *testing.T) { - tests := []struct { - name string - profileID string - wantErr bool - }{ - { - name: "valid profile with passthrough enabled", - profileID: "test-profile", - wantErr: false, - }, - { - name: "empty profile ID", - profileID: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr := newTestManager() - - // Override profile resolver for this test - if tt.profileID != "" { - mgr.profileResolver = &mockPassthroughProfileResolver{ - cliPassthrough: true, - } - } - - err := mgr.verifyPassthroughEnabled(context.Background(), "test-session", tt.profileID) - if (err != nil) != tt.wantErr { - t.Errorf("verifyPassthroughEnabled() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// mockPassthroughProfileResolver is a mock for testing passthrough verification -type mockPassthroughProfileResolver struct { - cliPassthrough bool - err error -} - -func (m *mockPassthroughProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { - if m.err != nil { - return nil, m.err - } - return &AgentProfileInfo{ - ProfileID: profileID, - CLIPassthrough: m.cliPassthrough, - }, nil -} - -// --- Tests for boot message (agent_boot, is_resuming, pollAgentStderr) --- - -// MockBootMessageService implements BootMessageService for testing -type MockBootMessageService struct { - mu sync.Mutex - CreatedMessages []*models.Message - UpdatedMessages []*models.Message - createErr error - updateErr error -} - -func (m *MockBootMessageService) CreateMessage(ctx context.Context, req *BootMessageRequest) (*models.Message, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.createErr != nil { - return nil, m.createErr - } - msg := &models.Message{ - ID: "boot-msg-" + req.TaskID, - TaskSessionID: req.TaskSessionID, - TaskID: req.TaskID, - Content: req.Content, - AuthorType: models.MessageAuthorType(req.AuthorType), - Type: models.MessageType(req.Type), - Metadata: req.Metadata, - } - m.CreatedMessages = append(m.CreatedMessages, msg) - return msg, nil -} - -func (m *MockBootMessageService) UpdateMessage(ctx context.Context, message *models.Message) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.updateErr != nil { - return m.updateErr - } - // Store a snapshot of the message at update time - snapshot := *message - metaCopy := make(map[string]interface{}) - for k, v := range message.Metadata { - metaCopy[k] = v - } - snapshot.Metadata = metaCopy - m.UpdatedMessages = append(m.UpdatedMessages, &snapshot) - return nil -} - -func (m *MockBootMessageService) getLastUpdatedMessage() *models.Message { - m.mu.Lock() - defer m.mu.Unlock() - if len(m.UpdatedMessages) == 0 { - return nil - } - return m.UpdatedMessages[len(m.UpdatedMessages)-1] -} - -func TestFinalizeBootMessage_Success(t *testing.T) { - mgr := newTestManager() - bootSvc := &MockBootMessageService{} - mgr.bootMessageService = bootSvc - - msg := &models.Message{ - ID: "boot-msg-1", - Metadata: map[string]interface{}{"status": "running"}, - } - stopCh := make(chan struct{}) - - mgr.finalizeBootMessage(msg, stopCh, nil, "exited") - - // Verify stop channel was closed - select { - case <-stopCh: - // good, channel was closed - default: - t.Error("expected stopCh to be closed") - } - - // Verify message was updated with final status - lastMsg := bootSvc.getLastUpdatedMessage() - if lastMsg == nil { - t.Fatal("expected boot message to be updated") - } else { - if lastMsg.Metadata["status"] != "exited" { - t.Errorf("expected status 'exited', got %v", lastMsg.Metadata["status"]) - } - if lastMsg.Metadata["exit_code"] != 0 { - t.Errorf("expected exit_code 0, got %v", lastMsg.Metadata["exit_code"]) - } - if lastMsg.Metadata["completed_at"] == nil { - t.Error("expected completed_at to be set") - } - } -} - -func TestFinalizeBootMessage_Failed(t *testing.T) { - mgr := newTestManager() - bootSvc := &MockBootMessageService{} - mgr.bootMessageService = bootSvc - - msg := &models.Message{ - ID: "boot-msg-1", - Metadata: map[string]interface{}{"status": "running"}, - } - stopCh := make(chan struct{}) - - mgr.finalizeBootMessage(msg, stopCh, nil, "failed") - - lastMsg := bootSvc.getLastUpdatedMessage() - if lastMsg == nil { - t.Fatal("expected boot message to be updated") - } else { - if lastMsg.Metadata["status"] != "failed" { - t.Errorf("expected status 'failed', got %v", lastMsg.Metadata["status"]) - } - // Failed status should NOT have exit_code - if _, ok := lastMsg.Metadata["exit_code"]; ok { - t.Error("expected no exit_code for failed status") - } - } -} - -func TestFinalizeBootMessage_NilMessage(t *testing.T) { - mgr := newTestManager() - bootSvc := &MockBootMessageService{} - mgr.bootMessageService = bootSvc - - // Should not panic with nil message - mgr.finalizeBootMessage(nil, nil, nil, "exited") - - bootSvc.mu.Lock() - defer bootSvc.mu.Unlock() - if len(bootSvc.UpdatedMessages) != 0 { - t.Error("expected no updates for nil message") - } -} - -func TestFinalizeBootMessage_NilService(t *testing.T) { - mgr := newTestManager() - // bootMessageService is nil by default - - msg := &models.Message{ - ID: "boot-msg-1", - Metadata: map[string]interface{}{"status": "running"}, - } - - // Should not panic with nil service - mgr.finalizeBootMessage(msg, nil, nil, "exited") -} - -func TestBootMessage_IsResumingFlag(t *testing.T) { - // Test that the is_resuming flag is correctly based on ACPSessionID presence - tests := []struct { - name string - acpSessionID string - wantResuming bool - }{ - { - name: "new session (no ACP session ID)", - acpSessionID: "", - wantResuming: false, - }, - { - name: "resumed session (has ACP session ID)", - acpSessionID: "acp-session-123", - wantResuming: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // The is_resuming logic is: execution.ACPSessionID != "" - isResuming := tt.acpSessionID != "" - if isResuming != tt.wantResuming { - t.Errorf("is_resuming = %v, want %v", isResuming, tt.wantResuming) - } - }) - } -} - -func TestPollAgentStderr_StopsOnClose(t *testing.T) { - mgr := newTestManager() - bootSvc := &MockBootMessageService{} - mgr.bootMessageService = bootSvc - - msg := &models.Message{ - ID: "boot-msg-1", - Metadata: map[string]interface{}{"status": "running"}, - } - stopCh := make(chan struct{}) - - // Start polling with nil client (will fail on each poll, but shouldn't panic) - done := make(chan struct{}) - go func() { - // Pass nil client - the pollAgentStderr will log errors but should exit on stop - // We can't use a nil client directly since it would panic on method call. - // Instead, just test that close(stopCh) causes the goroutine to exit. - close(stopCh) - done <- struct{}{} - }() - - select { - case <-done: - // Good, goroutine exited - case <-time.After(5 * time.Second): - t.Fatal("pollAgentStderr did not stop within timeout") - } - - _ = msg // msg would be used in a real poll -} - -// --- Tests for duplicate message prevention in handleAgentEvent --- - -// MockEventBusWithTracking provides detailed tracking of published events for testing -type MockEventBusWithTracking struct { - PublishedEvents []trackedEvent - mu sync.Mutex -} - -type trackedEvent struct { - Subject string - Event *bus.Event -} - -func (m *MockEventBusWithTracking) Publish(ctx context.Context, subject string, event *bus.Event) error { - m.mu.Lock() - defer m.mu.Unlock() - m.PublishedEvents = append(m.PublishedEvents, trackedEvent{Subject: subject, Event: event}) - return nil -} - -func (m *MockEventBusWithTracking) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) { - return nil, nil -} - -func (m *MockEventBusWithTracking) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) { - return nil, nil -} - -func (m *MockEventBusWithTracking) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) { - return nil, nil -} - -func (m *MockEventBusWithTracking) Close() {} - -func (m *MockEventBusWithTracking) IsConnected() bool { - return true -} - -func (m *MockEventBusWithTracking) getStreamEvents() []AgentStreamEventPayload { - m.mu.Lock() - defer m.mu.Unlock() - var result []AgentStreamEventPayload - for _, te := range m.PublishedEvents { - if payload, ok := te.Event.Data.(AgentStreamEventPayload); ok { - result = append(result, payload) - } - } - return result -} - -// createTestManagerWithTracking creates a manager with a tracking event bus for testing -func createTestManagerWithTracking() (*Manager, *MockEventBusWithTracking) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBusWithTracking{} - credsMgr := &MockCredentialsManager{} - profileResolver := &MockProfileResolver{} - mgr := NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, RuntimeFallbackWarn, log) - return mgr, eventBus -} - -// createTestExecution creates a test execution with proper initialization -func createTestExecution(id, taskID, sessionID string) *AgentExecution { - return &AgentExecution{ - ID: id, - TaskID: taskID, - SessionID: sessionID, - Status: v1.AgentStatusRunning, - StartedAt: time.Now(), - promptDoneCh: make(chan PromptCompletionSignal, 1), - } -} - -// TestHandleAgentEvent_StreamingThenComplete tests the normal flow: -// message_chunk events followed by complete event - should NOT create duplicate -func TestHandleAgentEvent_StreamingThenComplete(t *testing.T) { - mgr, eventBus := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Simulate streaming chunks with newlines (which trigger publishing) - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "Hello, world!\n", - }) - - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "This is a test.\n", - }) - - // Now send complete event - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - }) - - // Verify: streaming was used, so complete should NOT have text - events := eventBus.getStreamEvents() - - // Count message_streaming events (creates/appends) - var messageStreamingEvents []AgentStreamEventPayload - var completeEvents []AgentStreamEventPayload - for _, e := range events { - if e.Data != nil { - switch e.Data.Type { - case "message_streaming": - messageStreamingEvents = append(messageStreamingEvents, e) - case "complete": - completeEvents = append(completeEvents, e) - } - } - } - - // Should have streaming messages - if len(messageStreamingEvents) == 0 { - t.Error("expected message_streaming events, got none") - } - - // Should have exactly one complete event - if len(completeEvents) != 1 { - t.Errorf("expected 1 complete event, got %d", len(completeEvents)) - } - - // The complete event should NOT have text (streaming handled it via buffer) - if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" { - t.Errorf("complete event should not have text when streaming was used, got %q", completeEvents[0].Data.Text) - } -} - -// TestHandleAgentEvent_StreamingThenToolCallThenComplete tests the scenario that could cause duplicates: -// message_chunk → tool_call (clears currentMessageID) → complete -// This verifies that the buffer is properly flushed on complete after tool calls -func TestHandleAgentEvent_StreamingThenToolCallThenComplete(t *testing.T) { - mgr, eventBus := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Simulate streaming chunks - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "Let me check that for you.\n", - }) - - // Verify currentMessageID is set after message_chunk - execution.messageMu.Lock() - msgIDBeforeToolCall := execution.currentMessageID - execution.messageMu.Unlock() - - if msgIDBeforeToolCall == "" { - t.Error("currentMessageID should be set after message_chunk") - } - - // Tool call - this flushes buffer and clears currentMessageID - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "tool_call", - ToolCallID: "tool-1", - ToolName: "read_file", - }) - - // After tool call, currentMessageID should be cleared - execution.messageMu.Lock() - msgIDAfterToolCall := execution.currentMessageID - execution.messageMu.Unlock() - - if msgIDAfterToolCall != "" { - t.Errorf("currentMessageID should be cleared after tool_call, got %q", msgIDAfterToolCall) - } - - // Now complete - this should flush the buffer - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - }) - - events := eventBus.getStreamEvents() - - // Find the complete event - var completeEvents []AgentStreamEventPayload - for _, e := range events { - if e.Data != nil && e.Data.Type == "complete" { - completeEvents = append(completeEvents, e) - } - } - - if len(completeEvents) != 1 { - t.Errorf("expected 1 complete event, got %d", len(completeEvents)) - } - - // The complete event should NOT have text (streaming was used) - if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" { - t.Errorf("complete event should not have text when streaming was used (even after tool_call), got %q", - completeEvents[0].Data.Text) - } -} - -// TestHandleAgentEvent_CompleteWithoutStreaming verifies that complete events are -// properly handled when no streaming was used (buffer is empty). -// All adapters now send text via message_chunk events, so this tests the empty buffer case. -func TestHandleAgentEvent_CompleteWithoutStreaming(t *testing.T) { - mgr, eventBus := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Complete event without any prior streaming (e.g., agent did only tool calls) - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - }) - - events := eventBus.getStreamEvents() - - var completeEvents []AgentStreamEventPayload - for _, e := range events { - if e.Data != nil && e.Data.Type == "complete" { - completeEvents = append(completeEvents, e) - } - } - - if len(completeEvents) != 1 { - t.Errorf("expected 1 complete event, got %d", len(completeEvents)) - } - - // Complete event should be published successfully - // The buffer was empty, so no message_streaming events should be generated - var streamingEvents []AgentStreamEventPayload - for _, e := range events { - if e.Data != nil && e.Data.Type == "message_streaming" { - streamingEvents = append(streamingEvents, e) - } - } - - if len(streamingEvents) != 0 { - t.Errorf("expected 0 message_streaming events when buffer is empty, got %d", len(streamingEvents)) - } -} - -// TestHandleAgentEvent_CompleteWithBufferedText verifies that buffered text -// without streaming is emitted as a final streaming event on complete. -func TestHandleAgentEvent_CompleteWithBufferedText(t *testing.T) { - mgr, eventBus := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Buffer text without newlines (no streaming event should be emitted) - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "Final message without newline", - }) - - // Complete event should flush buffer into a streaming event - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - }) - - events := eventBus.getStreamEvents() - - var completeEvents []AgentStreamEventPayload - var streamingEvents []AgentStreamEventPayload - for _, e := range events { - if e.Data != nil { - switch e.Data.Type { - case "complete": - completeEvents = append(completeEvents, e) - case "message_streaming": - streamingEvents = append(streamingEvents, e) - } - } - } - - if len(completeEvents) != 1 { - t.Errorf("expected 1 complete event, got %d", len(completeEvents)) - } - - if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" { - t.Errorf("expected complete event to have empty text, got %q", completeEvents[0].Data.Text) - } - - if len(streamingEvents) != 1 { - t.Errorf("expected 1 message_streaming event when no newlines, got %d", len(streamingEvents)) - } else if streamingEvents[0].Data.Text != "Final message without newline" { - t.Errorf("expected streaming event to carry buffered text, got %q", streamingEvents[0].Data.Text) - } -} - -// TestHandleAgentEvent_CompleteThenMessageChunk tests the scenario where -// message_chunk arrives after complete. This documents the behavior when -// an adapter incorrectly sends text after the turn has completed. -// With the new architecture, adapters should NOT send message_chunk after complete. -func TestHandleAgentEvent_CompleteThenMessageChunk(t *testing.T) { - mgr, eventBus := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // First, simulate normal streaming during the turn - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "Processing your request...\n", - }) - - // Complete event arrives - this flushes the buffer - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - }) - - // Now message_chunk arrives AFTER complete - // This shouldn't happen with properly implemented adapters, - // but we document the behavior: it creates a new message - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "Done!\n", - }) - - events := eventBus.getStreamEvents() - - // Count message_streaming events - var messageStreamingEvents []AgentStreamEventPayload - for _, e := range events { - if e.Data != nil && e.Data.Type == "message_streaming" { - messageStreamingEvents = append(messageStreamingEvents, e) - } - } - - // Document the behavior: message_chunk after complete starts a new message - t.Logf("Got %d message_streaming events", len(messageStreamingEvents)) - for i, e := range messageStreamingEvents { - t.Logf(" Event %d: MessageID=%s, IsAppend=%v, Text=%q", - i, e.Data.MessageID, e.Data.IsAppend, e.Data.Text) - } - - // The second message_chunk (after complete) should start a NEW message - // since currentMessageID was cleared by the complete event - if len(messageStreamingEvents) >= 2 { - lastEvent := messageStreamingEvents[len(messageStreamingEvents)-1] - if !lastEvent.Data.IsAppend { - t.Log("Expected behavior: message_chunk after complete creates a new message") - } - } -} - -// TestHandleAgentEvent_MultipleToolCalls tests streaming → tool → streaming → tool → complete -func TestHandleAgentEvent_MultipleToolCalls(t *testing.T) { - mgr, eventBus := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Message before first tool - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "Let me read the file.\n", - }) - - // First tool call - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "tool_call", - ToolCallID: "tool-1", - ToolName: "read_file", - }) - - // Tool update (complete) - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "tool_update", - ToolCallID: "tool-1", - ToolStatus: "complete", - }) - - // Message after first tool - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "Now let me modify it.\n", - }) - - // Second tool call - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "tool_call", - ToolCallID: "tool-2", - ToolName: "write_file", - }) - - // Tool update (complete) - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "tool_update", - ToolCallID: "tool-2", - ToolStatus: "complete", - }) - - // Final message - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "Done with both tasks!\n", - }) - - // Complete - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - }) - - events := eventBus.getStreamEvents() - - // Count different event types - var messageStreamingCount, toolCallCount, completeCount int - for _, e := range events { - if e.Data != nil { - switch e.Data.Type { - case "message_streaming": - messageStreamingCount++ - case "tool_call": - toolCallCount++ - case "complete": - completeCount++ - } - } - } - - t.Logf("Events: message_streaming=%d, tool_call=%d, complete=%d", - messageStreamingCount, toolCallCount, completeCount) - - // Should have multiple streaming messages (one per "segment" before tool calls) - if messageStreamingCount < 3 { - t.Errorf("expected at least 3 message_streaming events for 3 message segments, got %d", messageStreamingCount) - } - - if toolCallCount != 2 { - t.Errorf("expected 2 tool_call events, got %d", toolCallCount) - } - - if completeCount != 1 { - t.Errorf("expected 1 complete event, got %d", completeCount) - } - - // Find the complete event and verify it has no text - for _, e := range events { - if e.Data != nil && e.Data.Type == "complete" { - if e.Data.Text != "" { - t.Errorf("complete event should not have text when streaming was used, got %q", e.Data.Text) - } - } - } -} - -// TestHandleAgentEvent_CompleteSignalsPromptDoneCh verifies that the complete event -// signals the promptDoneCh channel with the correct stop reason. -func TestHandleAgentEvent_CompleteSignalsPromptDoneCh(t *testing.T) { - mgr, _ := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Send a normal complete event - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - }) - - // promptDoneCh should have a signal - select { - case signal := <-execution.promptDoneCh: - if signal.IsError { - t.Error("expected non-error signal for normal complete") - } - if signal.StopReason != "end_turn" { - t.Errorf("expected stop_reason 'end_turn', got %q", signal.StopReason) - } - default: - t.Error("expected signal on promptDoneCh, got none") - } -} - -// TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh verifies that an error completion -// signals the promptDoneCh channel with IsError=true and the error message. -func TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh(t *testing.T) { - mgr, _ := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Send an error complete event - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - Error: "something went wrong", - Data: map[string]interface{}{"is_error": true}, - }) - - // promptDoneCh should have an error signal - select { - case signal := <-execution.promptDoneCh: - if !signal.IsError { - t.Error("expected error signal for error complete") - } - if signal.StopReason != "error" { - t.Errorf("expected stop_reason 'error', got %q", signal.StopReason) - } - if signal.Error != "something went wrong" { - t.Errorf("expected error 'something went wrong', got %q", signal.Error) - } - default: - t.Error("expected signal on promptDoneCh, got none") - } -} - -// TestHandleAgentEvent_UpdatesLastActivityAt verifies that every agent event -// updates the lastActivityAt timestamp for stall detection. -func TestHandleAgentEvent_UpdatesLastActivityAt(t *testing.T) { - mgr, _ := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Set lastActivityAt to a known old time - oldTime := time.Now().Add(-10 * time.Minute) - execution.lastActivityAtMu.Lock() - execution.lastActivityAt = oldTime - execution.lastActivityAtMu.Unlock() - - // Send any event - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "message_chunk", - Text: "hello\n", - }) - - // lastActivityAt should be updated to approximately now - execution.lastActivityAtMu.Lock() - elapsed := time.Since(execution.lastActivityAt) - execution.lastActivityAtMu.Unlock() - - if elapsed > 1*time.Second { - t.Errorf("lastActivityAt not updated: elapsed %v since last event", elapsed) - } -} - -// TestHandleAgentEvent_PromptDoneChDoesNotBlockWhenFull verifies that signaling -// promptDoneCh with a full channel (no receiver) doesn't block the event handler. -func TestHandleAgentEvent_PromptDoneChDoesNotBlockWhenFull(t *testing.T) { - mgr, _ := createTestManagerWithTracking() - execution := createTestExecution("exec-1", "task-1", "session-1") - mgr.executionStore.Add(execution) - - // Pre-fill the channel - execution.promptDoneCh <- PromptCompletionSignal{StopReason: "stale"} - - // Send complete event — should not block - done := make(chan struct{}) - go func() { - mgr.handleAgentEvent(execution, agentctl.AgentEvent{ - Type: "complete", - }) - close(done) - }() - - select { - case <-done: - // Good — didn't block - case <-time.After(2 * time.Second): - t.Fatal("handleAgentEvent blocked when promptDoneCh was full") - } -} diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go index aa9afb7cd0..02eeb7d8cd 100644 --- a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go +++ b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go @@ -102,7 +102,8 @@ func TestNewStoreProfileResolver(t *testing.T) { if resolver == nil { t.Fatal("expected non-nil resolver") - } else if resolver.store != mockRepo { + } + if resolver.store != mockRepo { t.Error("expected resolver to use the provided store") } } @@ -137,28 +138,27 @@ func TestStoreProfileResolver_ResolveProfile_Success(t *testing.T) { } if info == nil { t.Fatal("expected non-nil profile info") - } else { - if info.ProfileID != "profile-123" { - t.Errorf("expected ProfileID 'profile-123', got '%s'", info.ProfileID) - } - if info.ProfileName != "My Profile" { - t.Errorf("expected ProfileName 'My Profile', got '%s'", info.ProfileName) - } - if info.AgentID != "agent-456" { - t.Errorf("expected AgentID 'agent-456', got '%s'", info.AgentID) - } - if info.AgentName != "claude" { - t.Errorf("expected AgentName 'claude', got '%s'", info.AgentName) - } - if info.Model != "claude-3.5-sonnet" { - t.Errorf("expected Model 'claude-3.5-sonnet', got '%s'", info.Model) - } - if info.AutoApprove != true { - t.Error("expected AutoApprove to be true") - } - if info.DangerouslySkipPermissions != false { - t.Error("expected DangerouslySkipPermissions to be false") - } + } + if info.ProfileID != "profile-123" { + t.Errorf("expected ProfileID 'profile-123', got '%s'", info.ProfileID) + } + if info.ProfileName != "My Profile" { + t.Errorf("expected ProfileName 'My Profile', got '%s'", info.ProfileName) + } + if info.AgentID != "agent-456" { + t.Errorf("expected AgentID 'agent-456', got '%s'", info.AgentID) + } + if info.AgentName != "claude" { + t.Errorf("expected AgentName 'claude', got '%s'", info.AgentName) + } + if info.Model != "claude-3.5-sonnet" { + t.Errorf("expected Model 'claude-3.5-sonnet', got '%s'", info.Model) + } + if info.AutoApprove != true { + t.Error("expected AutoApprove to be true") + } + if info.DangerouslySkipPermissions != false { + t.Error("expected DangerouslySkipPermissions to be false") } } @@ -265,7 +265,8 @@ func TestStoreProfileResolver_ResolveProfile_FallbackToRegistryDefaultModel(t *t } if info == nil { t.Fatal("expected non-nil profile info") - } else if info.Model != "claude-sonnet-4-20250514" { + } + if info.Model != "claude-sonnet-4-20250514" { // Should have fallback to registry's default model t.Errorf("expected Model 'claude-sonnet-4-20250514' (from registry), got '%s'", info.Model) } @@ -300,7 +301,8 @@ func TestStoreProfileResolver_ResolveProfile_EmptyModelNoRegistry(t *testing.T) } if info == nil { t.Fatal("expected non-nil profile info") - } else if info.Model != "" { + } + if info.Model != "" { // Model should remain empty since no registry fallback available t.Errorf("expected empty Model when no registry fallback, got '%s'", info.Model) } diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index a1dd7b773c..40477ee54d 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -314,16 +314,20 @@ func (sm *SessionManager) InitializeAndPrompt( sm.eventPublisher.PublishACPSessionCreated(execution, result.SessionID) } - // Send the task prompt if provided - run asynchronously so orchestrator.start returns quickly. - // The agent will process the prompt and emit events via the WebSocket stream. - // - // For resumed sessions (fork_session pattern): If we have session history but no new task description, - // we still need to send a resume context prompt so the agent knows about the prior conversation. - if taskDescription != "" { - // Inject Kandev system prompt and session context - promptWithContext := sm.injectKandevContext(execution.TaskID, execution.SessionID, taskDescription) + // Send the task prompt if provided, or mark the execution as ready. + sm.dispatchInitialPrompt(ctx, execution, agentConfig, taskDescription, markReady) - // Check if we need to inject resume context (fork_session pattern for ACP agents) + return nil +} + +// dispatchInitialPrompt sends the initial task prompt or marks the execution as ready. +// For sessions with a task description, sends the prompt asynchronously. +// For resumed sessions (fork_session pattern), defers context injection to the first user message. +// For all other cases, marks the execution ready immediately. +func (sm *SessionManager) dispatchInitialPrompt(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, markReady func(executionID string) error) { + switch { + case taskDescription != "": + promptWithContext := sm.injectKandevContext(execution.TaskID, execution.SessionID, taskDescription) effectivePrompt := sm.getResumeContextPrompt(agentConfig, execution.SessionID, promptWithContext) if effectivePrompt != taskDescription { sm.logger.Info("injecting resume context into initial prompt", @@ -332,14 +336,9 @@ func (sm *SessionManager) InitializeAndPrompt( zap.Int("original_length", len(taskDescription)), zap.Int("effective_length", len(effectivePrompt))) } - go func() { - // Use detached context that respects stopCh for graceful shutdown - // Use PromptTimeout (60 min) to match the HTTP client/server timeouts promptCtx, cancel := appctx.Detached(ctx, sm.stopCh, constants.PromptTimeout) defer cancel() - - // Initial prompt has no attachments _, err := sm.SendPrompt(promptCtx, execution, effectivePrompt, false, nil) if err != nil { sm.logger.Error("initial prompt failed", @@ -347,10 +346,7 @@ func (sm *SessionManager) InitializeAndPrompt( zap.Error(err)) } }() - } else if sm.shouldInjectResumeContext(agentConfig, execution.SessionID) { - // No task description, but we have session history (resumed session) - // Don't send a prompt now - we'll inject context when the user sends their first message. - // This avoids triggering IN_PROGRESS state until the user actually interacts. + case sm.shouldInjectResumeContext(agentConfig, execution.SessionID): execution.needsResumeContext = true sm.logger.Info("session has history for context injection, will inject on first user prompt", zap.String("execution_id", execution.ID), @@ -360,7 +356,7 @@ func (sm *SessionManager) InitializeAndPrompt( zap.String("execution_id", execution.ID), zap.Error(err)) } - } else { + default: sm.logger.Debug("no task description and no resume context needed, marking as ready", zap.String("execution_id", execution.ID)) if err := markReady(execution.ID); err != nil { @@ -369,112 +365,41 @@ func (sm *SessionManager) InitializeAndPrompt( zap.Error(err)) } } - - return nil } -// SendPrompt sends a prompt to an agent execution and waits for completion. -// For initial prompts, pass validateStatus=false. For follow-up prompts, pass validateStatus=true. -// Attachments (images) are passed to the agent if provided. -// Returns the prompt result containing the stop reason and agent message. -// Note: MarkReady is handled by handleAgentEvent(complete), not by this method. -func (sm *SessionManager) SendPrompt( - ctx context.Context, - execution *AgentExecution, - prompt string, - validateStatus bool, - attachments []v1.MessageAttachment, -) (*PromptResult, error) { - if execution.agentctl == nil { - return nil, fmt.Errorf("execution %q has no agentctl client", execution.ID) +// buildEffectivePrompt applies resume context injection if needed, returning the effective prompt to send. +func (sm *SessionManager) buildEffectivePrompt(execution *AgentExecution, prompt string) string { + if !execution.needsResumeContext || execution.resumeContextInjected { + return prompt } - - // For follow-up prompts, validate status and update to RUNNING - if validateStatus { - if execution.Status != v1.AgentStatusRunning && execution.Status != v1.AgentStatusReady { - return nil, fmt.Errorf("execution %q is not ready for prompts (status: %s)", execution.ID, execution.Status) - } - if sm.executionStore != nil { - sm.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning) + // Inject Kandev context for resumed sessions (first prompt in this execution) + promptWithContext := sm.injectKandevContext(execution.TaskID, execution.SessionID, prompt) + effectivePrompt := promptWithContext + if sm.historyManager != nil { + resumePrompt, err := sm.historyManager.GenerateResumeContext(execution.SessionID, promptWithContext) + switch { + case err != nil: + sm.logger.Warn("failed to generate resume context for follow-up prompt", + zap.String("execution_id", execution.ID), + zap.Error(err)) + case resumePrompt != promptWithContext: + effectivePrompt = resumePrompt + sm.logger.Info("injecting resume context into follow-up prompt", + zap.String("execution_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.Int("original_length", len(prompt)), + zap.Int("effective_length", len(effectivePrompt))) + sm.logger.Info("resume context prompt content", + zap.String("execution_id", execution.ID), + zap.String("resume_prompt", effectivePrompt)) } } + execution.resumeContextInjected = true + return effectivePrompt +} - // Clear buffers and streaming state before starting prompt - // This ensures each prompt starts fresh and doesn't append to previous message - execution.messageMu.Lock() - execution.messageBuffer.Reset() - execution.thinkingBuffer.Reset() - execution.currentMessageID = "" // Clear streaming message ID for new turn - execution.currentThinkingID = "" // Clear streaming thinking ID for new turn - execution.messageMu.Unlock() - - // Check if we need to inject resume context (fork_session pattern) - // This happens on the first user prompt after resuming a session - // For resumed sessions, we also inject the Kandev context since it's the first prompt in this execution - effectivePrompt := prompt - if execution.needsResumeContext && !execution.resumeContextInjected { - // Inject Kandev context for resumed sessions (first prompt in this execution) - promptWithContext := sm.injectKandevContext(execution.TaskID, execution.SessionID, prompt) - if sm.historyManager != nil { - resumePrompt, err := sm.historyManager.GenerateResumeContext(execution.SessionID, promptWithContext) - if err != nil { - sm.logger.Warn("failed to generate resume context for follow-up prompt", - zap.String("execution_id", execution.ID), - zap.Error(err)) - effectivePrompt = promptWithContext - } else if resumePrompt != promptWithContext { - effectivePrompt = resumePrompt - sm.logger.Info("injecting resume context into follow-up prompt", - zap.String("execution_id", execution.ID), - zap.String("session_id", execution.SessionID), - zap.Int("original_length", len(prompt)), - zap.Int("effective_length", len(effectivePrompt))) - // Log the full resume context for debugging (use Info for now so user can inspect) - sm.logger.Info("resume context prompt content", - zap.String("execution_id", execution.ID), - zap.String("resume_prompt", effectivePrompt)) - } else { - effectivePrompt = promptWithContext - } - } else { - effectivePrompt = promptWithContext - } - execution.resumeContextInjected = true - } - - sm.logger.Info("sending prompt to agent", - zap.String("execution_id", execution.ID), - zap.Int("prompt_length", len(effectivePrompt)), - zap.Int("attachments_count", len(attachments))) - - // Store user prompt to session history for context injection (store original, not with injected context) - if sm.historyManager != nil && execution.SessionID != "" { - if err := sm.historyManager.AppendUserMessage(execution.SessionID, prompt); err != nil { - sm.logger.Warn("failed to store user message to history", zap.Error(err)) - } - } - - // Initialize activity timestamp for stall detection - execution.lastActivityAtMu.Lock() - execution.lastActivityAt = time.Now() - execution.lastActivityAtMu.Unlock() - - // Drain any stale signal from a previous prompt cycle - select { - case <-execution.promptDoneCh: - default: - } - - // Fire the prompt (returns immediately now — completion comes via WebSocket complete event) - if err := execution.agentctl.Prompt(ctx, effectivePrompt, attachments); err != nil { - sm.logger.Error("failed to trigger prompt", - zap.String("execution_id", execution.ID), - zap.Error(err)) - return nil, fmt.Errorf("failed to trigger prompt: %w", err) - } - - // Wait for completion signal from handleAgentEvent(complete) or stream disconnect. - // Periodically check for stall conditions (no events for an extended period). +// waitForPromptDone waits for the prompt to complete, checking for stalls periodically. +func (sm *SessionManager) waitForPromptDone(ctx context.Context, execution *AgentExecution) (*PromptResult, error) { stallTicker := time.NewTicker(30 * time.Second) defer stallTicker.Stop() @@ -522,3 +447,76 @@ func (sm *SessionManager) SendPrompt( } } } + +// SendPrompt sends a prompt to an agent execution and waits for completion. +// For initial prompts, pass validateStatus=false. For follow-up prompts, pass validateStatus=true. +// Attachments (images) are passed to the agent if provided. +// Returns the prompt result containing the stop reason and agent message. +// Note: MarkReady is handled by handleAgentEvent(complete), not by this method. +func (sm *SessionManager) SendPrompt( + ctx context.Context, + execution *AgentExecution, + prompt string, + validateStatus bool, + attachments []v1.MessageAttachment, +) (*PromptResult, error) { + if execution.agentctl == nil { + return nil, fmt.Errorf("execution %q has no agentctl client", execution.ID) + } + + // For follow-up prompts, validate status and update to RUNNING + if validateStatus { + if execution.Status != v1.AgentStatusRunning && execution.Status != v1.AgentStatusReady { + return nil, fmt.Errorf("execution %q is not ready for prompts (status: %s)", execution.ID, execution.Status) + } + if sm.executionStore != nil { + sm.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning) + } + } + + // Clear buffers and streaming state before starting prompt + // This ensures each prompt starts fresh and doesn't append to previous message + execution.messageMu.Lock() + execution.messageBuffer.Reset() + execution.thinkingBuffer.Reset() + execution.currentMessageID = "" // Clear streaming message ID for new turn + execution.currentThinkingID = "" // Clear streaming thinking ID for new turn + execution.messageMu.Unlock() + + // Apply resume context injection if needed (first prompt after resume) + effectivePrompt := sm.buildEffectivePrompt(execution, prompt) + + sm.logger.Info("sending prompt to agent", + zap.String("execution_id", execution.ID), + zap.Int("prompt_length", len(effectivePrompt)), + zap.Int("attachments_count", len(attachments))) + + // Store user prompt to session history for context injection (store original, not with injected context) + if sm.historyManager != nil && execution.SessionID != "" { + if err := sm.historyManager.AppendUserMessage(execution.SessionID, prompt); err != nil { + sm.logger.Warn("failed to store user message to history", zap.Error(err)) + } + } + + // Initialize activity timestamp for stall detection + execution.lastActivityAtMu.Lock() + execution.lastActivityAt = time.Now() + execution.lastActivityAtMu.Unlock() + + // Drain any stale signal from a previous prompt cycle + select { + case <-execution.promptDoneCh: + default: + } + + // Fire the prompt (returns immediately now — completion comes via WebSocket complete event) + if err := execution.agentctl.Prompt(ctx, effectivePrompt, attachments); err != nil { + sm.logger.Error("failed to trigger prompt", + zap.String("execution_id", execution.ID), + zap.Error(err)) + return nil, fmt.Errorf("failed to trigger prompt: %w", err) + } + + // Wait for completion signal from handleAgentEvent(complete) or stream disconnect. + return sm.waitForPromptDone(ctx, execution) +} diff --git a/apps/backend/internal/agent/lifecycle/session_history.go b/apps/backend/internal/agent/lifecycle/session_history.go index ec3187d0e5..ad0ed0bcbd 100644 --- a/apps/backend/internal/agent/lifecycle/session_history.go +++ b/apps/backend/internal/agent/lifecycle/session_history.go @@ -18,6 +18,11 @@ import ( "github.com/kandev/kandev/internal/common/logger" ) +// History entry type constants. +const ( + historyEntryTypeToolCall = "tool_call" +) + // SessionHistoryManager stores and retrieves session history for context injection. // This enables the fork_session pattern for ACP agents that don't support session/load. // @@ -132,7 +137,7 @@ func (m *SessionHistoryManager) AppendAgentMessage(sessionID, message string) er // AppendToolCall appends a tool call to the session history. func (m *SessionHistoryManager) AppendToolCall(sessionID string, event agentctl.AgentEvent) error { return m.AppendEntry(sessionID, HistoryEntry{ - Type: "tool_call", + Type: historyEntryTypeToolCall, ToolCallID: event.ToolCallID, ToolName: event.ToolName, ToolStatus: event.ToolStatus, @@ -217,7 +222,7 @@ func (m *SessionHistoryManager) GenerateResumeContext(sessionID, newPrompt strin fmt.Fprintf(&historyBuilder, "\n[USER]: %s\n", truncateForContext(entry.Content, 2000)) case "agent_message": fmt.Fprintf(&historyBuilder, "\n[ASSISTANT]: %s\n", truncateForContext(entry.Content, 2000)) - case "tool_call": + case historyEntryTypeToolCall: fmt.Fprintf(&historyBuilder, "\n[TOOL CALL: %s]\n", entry.ToolName) case "tool_result": fmt.Fprintf(&historyBuilder, "\n[TOOL RESULT: %s] %s\n", entry.ToolName, truncateForContext(entry.Content, 500)) diff --git a/apps/backend/internal/agent/lifecycle/session_test.go b/apps/backend/internal/agent/lifecycle/session_test.go index 5654d79b23..1261d423ae 100644 --- a/apps/backend/internal/agent/lifecycle/session_test.go +++ b/apps/backend/internal/agent/lifecycle/session_test.go @@ -75,24 +75,21 @@ func newMockAgentServer(t *testing.T) *mockAgentServer { continue } - if msg.Type == ws.MessageTypeRequest { - m.mu.Lock() - m.actionLog = append(m.actionLog, msg.Action) - m.mu.Unlock() + if msg.Type != ws.MessageTypeRequest { + continue + } - var resp *ws.Message - if m.handler != nil { - resp = m.handler(msg) - } else { - resp = m.defaultHandler(msg) - } + m.mu.Lock() + m.actionLog = append(m.actionLog, msg.Action) + m.mu.Unlock() - if resp != nil { - data, _ := json.Marshal(resp) - if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { - return - } - } + resp := m.buildResponse(msg) + if resp == nil { + continue + } + data, _ := json.Marshal(resp) + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + return } } }) @@ -122,6 +119,14 @@ func newMockAgentServer(t *testing.T) *mockAgentServer { return m } +// buildResponse returns the handler or default response for a request message. +func (m *mockAgentServer) buildResponse(msg ws.Message) *ws.Message { + if m.handler != nil { + return m.handler(msg) + } + return m.defaultHandler(msg) +} + func (m *mockAgentServer) defaultHandler(msg ws.Message) *ws.Message { switch msg.Action { case "agent.initialize": diff --git a/apps/backend/internal/agent/lifecycle/streams.go b/apps/backend/internal/agent/lifecycle/streams.go index e5b3e707c0..27e2607120 100644 --- a/apps/backend/internal/agent/lifecycle/streams.go +++ b/apps/backend/internal/agent/lifecycle/streams.go @@ -124,6 +124,62 @@ func (sm *StreamManager) connectUpdatesStream(execution *AgentExecution, ready c } } +// buildWorkspaceCallbacks creates the WorkspaceStreamCallbacks for a given execution, +// wiring each callback to the StreamManager's registered handlers. +func (sm *StreamManager) buildWorkspaceCallbacks(execution *AgentExecution) agentctl.WorkspaceStreamCallbacks { + return agentctl.WorkspaceStreamCallbacks{ + OnShellOutput: func(data string) { + if sm.callbacks.OnShellOutput != nil { + sm.callbacks.OnShellOutput(execution, data) + } + }, + OnShellExit: func(code int) { + if sm.callbacks.OnShellExit != nil { + sm.callbacks.OnShellExit(execution, code) + } + }, + OnGitStatus: func(update *agentctl.GitStatusUpdate) { + if sm.callbacks.OnGitStatus != nil { + sm.callbacks.OnGitStatus(execution, update) + } + }, + OnGitCommit: func(commit *agentctl.GitCommitNotification) { + if sm.callbacks.OnGitCommit != nil { + sm.callbacks.OnGitCommit(execution, commit) + } + }, + OnGitReset: func(reset *agentctl.GitResetNotification) { + if sm.callbacks.OnGitReset != nil { + sm.callbacks.OnGitReset(execution, reset) + } + }, + OnFileChange: func(notification *agentctl.FileChangeNotification) { + if sm.callbacks.OnFileChange != nil { + 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)) + }, + OnError: func(err string) { + sm.logger.Debug("workspace stream error", + zap.String("instance_id", execution.ID), + zap.String("error", err)) + }, + } +} + // connectWorkspaceStream handles the unified workspace stream with retry logic func (sm *StreamManager) connectWorkspaceStream(execution *AgentExecution, ready chan<- struct{}) { ctx := context.Background() @@ -145,57 +201,7 @@ func (sm *StreamManager) connectWorkspaceStream(execution *AgentExecution, ready defer signalReady() for attempt := 1; attempt <= maxRetries; attempt++ { - callbacks := agentctl.WorkspaceStreamCallbacks{ - OnShellOutput: func(data string) { - if sm.callbacks.OnShellOutput != nil { - sm.callbacks.OnShellOutput(execution, data) - } - }, - OnShellExit: func(code int) { - if sm.callbacks.OnShellExit != nil { - sm.callbacks.OnShellExit(execution, code) - } - }, - OnGitStatus: func(update *agentctl.GitStatusUpdate) { - if sm.callbacks.OnGitStatus != nil { - sm.callbacks.OnGitStatus(execution, update) - } - }, - OnGitCommit: func(commit *agentctl.GitCommitNotification) { - if sm.callbacks.OnGitCommit != nil { - sm.callbacks.OnGitCommit(execution, commit) - } - }, - OnGitReset: func(reset *agentctl.GitResetNotification) { - if sm.callbacks.OnGitReset != nil { - sm.callbacks.OnGitReset(execution, reset) - } - }, - OnFileChange: func(notification *agentctl.FileChangeNotification) { - if sm.callbacks.OnFileChange != nil { - 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)) - }, - OnError: func(err string) { - sm.logger.Debug("workspace stream error", - zap.String("instance_id", execution.ID), - zap.String("error", err)) - }, - } + callbacks := sm.buildWorkspaceCallbacks(execution) ws, err := execution.agentctl.StreamWorkspace(ctx, callbacks) if err != nil { diff --git a/apps/backend/internal/agent/mcpconfig/resolve.go b/apps/backend/internal/agent/mcpconfig/resolve.go index 1febeb5c27..43d30bc8ba 100644 --- a/apps/backend/internal/agent/mcpconfig/resolve.go +++ b/apps/backend/internal/agent/mcpconfig/resolve.go @@ -16,81 +16,108 @@ func Resolve(config *ProfileConfig, policy Policy) ([]ResolvedServer, []string, resolved := make([]ResolvedServer, 0, len(config.Servers)) for name, server := range config.Servers { - // Skip kandev server - it's automatically injected by agentctl with the correct local URL. - // This handles existing profiles that still have the old kandev config in the database. - if name == "kandev" { - continue + rs, warn, err := resolveServer(name, server, policy) + warnings = append(warnings, warn...) + if err != nil { + return nil, warnings, err } - - if !policyAllowsServerName(policy, name) { - warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: server not allowed by policy", name)) - continue + if rs != nil { + resolved = append(resolved, *rs) } - - serverType := normalizeServerType(server) - if serverType == "" { - warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: missing type", name)) - continue - } - - mode := server.Mode - if mode == "" || mode == ServerModeAuto { - if serverType == ServerTypeStdio { - mode = ServerModePerSession - } else { - mode = ServerModeShared - } - } - - if serverType == ServerTypeStdio && mode == ServerModeShared { - return nil, warnings, fmt.Errorf("mcp server %q: shared mode requires HTTP/SSE/streamable HTTP transport (stdio is per-session only)", name) - } - - if !policyAllows(policy, serverType) { - warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: transport %q not allowed", name, serverType)) - continue - } - - if serverType == ServerTypeStdio && strings.TrimSpace(server.Command) == "" { - warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: stdio server missing command", name)) - continue - } - - if serverType != ServerTypeStdio && strings.TrimSpace(server.URL) == "" { - warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: http server missing url", name)) - continue - } - - env := map[string]string{} - for k, v := range policy.EnvInjection { - env[k] = v - } - for k, v := range server.Env { - env[k] = v - } - - url := server.URL - if url != "" { - if rewritten, ok := policy.URLRewrite[url]; ok { - url = rewritten - } - } - - resolved = append(resolved, ResolvedServer{ - Name: name, - Type: serverType, - Mode: mode, - Command: server.Command, - Args: append([]string{}, server.Args...), - Env: env, - URL: url, - Headers: cloneStringMap(server.Headers), - }) } return resolved, warnings, nil } +// resolveServer resolves a single server entry against the policy. +// Returns (nil, warnings, nil) when the server should be skipped. +// Returns (nil, warnings, err) on a fatal configuration error. +func resolveServer(name string, server ServerDef, policy Policy) (*ResolvedServer, []string, error) { + // Skip kandev server - it's automatically injected by agentctl with the correct local URL. + // This handles existing profiles that still have the old kandev config in the database. + if name == "kandev" { + return nil, nil, nil + } + + if !policyAllowsServerName(policy, name) { + return nil, []string{fmt.Sprintf("mcp server %q skipped: server not allowed by policy", name)}, nil + } + + serverType := normalizeServerType(server) + if serverType == "" { + return nil, []string{fmt.Sprintf("mcp server %q skipped: missing type", name)}, nil + } + + mode := resolveServerMode(server.Mode, serverType) + + if serverType == ServerTypeStdio && mode == ServerModeShared { + return nil, nil, fmt.Errorf("mcp server %q: shared mode requires HTTP/SSE/streamable HTTP transport (stdio is per-session only)", name) + } + + if !policyAllows(policy, serverType) { + return nil, []string{fmt.Sprintf("mcp server %q skipped: transport %q not allowed", name, serverType)}, nil + } + + if warn := validateServerTransport(name, server, serverType); warn != "" { + return nil, []string{warn}, nil + } + + rs := buildResolvedServer(name, server, policy, serverType, mode) + return &rs, nil, nil +} + +// resolveServerMode determines the effective mode for a server. +func resolveServerMode(mode ServerMode, serverType ServerType) ServerMode { + if mode == "" || mode == ServerModeAuto { + if serverType == ServerTypeStdio { + return ServerModePerSession + } + return ServerModeShared + } + return mode +} + +// validateServerTransport checks that stdio servers have a command and HTTP servers have a URL. +// Returns a warning string if validation fails, or empty string if valid. +func validateServerTransport(name string, server ServerDef, serverType ServerType) string { + if serverType == ServerTypeStdio && strings.TrimSpace(server.Command) == "" { + return fmt.Sprintf("mcp server %q skipped: stdio server missing command", name) + } + if serverType != ServerTypeStdio && strings.TrimSpace(server.URL) == "" { + return fmt.Sprintf("mcp server %q skipped: http server missing url", name) + } + return "" +} + +// buildResolvedServer constructs a ResolvedServer, merging policy env injection and URL rewriting. +func buildResolvedServer(name string, server ServerDef, policy Policy, serverType ServerType, mode ServerMode) ResolvedServer { + env := map[string]string{} + for k, v := range policy.EnvInjection { + env[k] = v + } + for k, v := range server.Env { + env[k] = v + } + + url := server.URL + if url != "" { + if rewritten, ok := policy.URLRewrite[url]; ok { + url = rewritten + } + } + + return ResolvedServer{ + Name: name, + Type: serverType, + Mode: mode, + Command: server.Command, + Args: append([]string{}, server.Args...), + Env: env, + URL: url, + Headers: cloneStringMap(server.Headers), + } +} + func normalizeServerType(server ServerDef) ServerType { if server.Type != "" { return server.Type diff --git a/apps/backend/internal/agent/registry/provider.go b/apps/backend/internal/agent/registry/provider.go index 78acded059..7a95935b30 100644 --- a/apps/backend/internal/agent/registry/provider.go +++ b/apps/backend/internal/agent/registry/provider.go @@ -15,18 +15,29 @@ func Provide(log *logger.Logger) (*Registry, func() error, error) { reg.LoadDefaults() if os.Getenv("KANDEV_MOCK_AGENT") == "true" { - if ag, ok := reg.Get("mock-agent"); ok { - if mock, ok := ag.(*agents.MockAgent); ok { - mock.SetEnabled(true) - // Resolve binary path: same directory as the running executable - if exePath, err := os.Executable(); err == nil { - binaryPath := filepath.Join(filepath.Dir(exePath), "mock-agent") - mock.SetBinaryPath(binaryPath) - log.Info("mock agent enabled", zap.String("cmd", binaryPath)) - } - } - } + configureMockAgent(reg, log) } return reg, func() error { return nil }, nil } + +// configureMockAgent enables and configures the mock agent binary path. +func configureMockAgent(reg *Registry, log *logger.Logger) { + ag, ok := reg.Get("mock-agent") + if !ok { + return + } + mock, ok := ag.(*agents.MockAgent) + if !ok { + return + } + mock.SetEnabled(true) + // Resolve binary path: same directory as the running executable + exePath, err := os.Executable() + if err != nil { + return + } + binaryPath := filepath.Join(filepath.Dir(exePath), "mock-agent") + mock.SetBinaryPath(binaryPath) + log.Info("mock agent enabled", zap.String("cmd", binaryPath)) +} diff --git a/apps/backend/internal/agent/settings/controller/agent_config.go b/apps/backend/internal/agent/settings/controller/agent_config.go new file mode 100644 index 0000000000..7e4bc12b81 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_config.go @@ -0,0 +1,298 @@ +package controller + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" + "go.uber.org/zap" +) + +type UpdateAgentProfileMcpConfigRequest struct { + Enabled bool + Servers map[string]mcpconfig.ServerDef + Meta map[string]any +} + +func (c *Controller) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*dto.AgentProfileMcpConfigDTO, error) { + config, err := c.mcpService.GetConfigByProfileID(ctx, profileID) + if err != nil { + if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) { + return nil, ErrAgentProfileNotFound + } + if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) { + return nil, ErrAgentMcpUnsupported + } + return nil, err + } + return &dto.AgentProfileMcpConfigDTO{ + ProfileID: config.ProfileID, + Enabled: config.Enabled, + Servers: config.Servers, + Meta: config.Meta, + }, nil +} + +func (c *Controller) UpdateAgentProfileMcpConfig(ctx context.Context, profileID string, req UpdateAgentProfileMcpConfigRequest) (*dto.AgentProfileMcpConfigDTO, error) { + config, err := c.mcpService.UpsertConfigByProfileID(ctx, profileID, &mcpconfig.ProfileConfig{ + Enabled: req.Enabled, + Servers: req.Servers, + Meta: req.Meta, + }) + if err != nil { + if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) { + return nil, ErrAgentProfileNotFound + } + if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) { + return nil, ErrAgentMcpUnsupported + } + return nil, err + } + return &dto.AgentProfileMcpConfigDTO{ + ProfileID: config.ProfileID, + Enabled: config.Enabled, + Servers: config.Servers, + Meta: config.Meta, + }, nil +} + +// EnsureDefaultMcpConfig ensures all agent profiles that support MCP have +// MCP enabled by default. The kandev MCP server is automatically injected +// by agentctl at session creation time, so profiles don't need to explicitly +// configure it. +func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error { + agentList, err := c.repo.ListAgents(ctx) + if err != nil { + return err + } + + for _, agent := range agentList { + if !agent.SupportsMCP { + continue + } + + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return err + } + + for _, profile := range profiles { + if err := c.ensureProfileMcpConfig(ctx, profile); err != nil { + return err + } + } + } + + return nil +} + +func (c *Controller) ensureProfileMcpConfig(ctx context.Context, profile *models.AgentProfile) error { + // Check if MCP config already exists + existingConfig, err := c.repo.GetAgentProfileMcpConfig(ctx, profile.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + // Skip if config already exists (don't overwrite user settings) + if existingConfig != nil { + return nil + } + + // Create default MCP config with MCP enabled but no servers configured. + // The kandev MCP server is automatically injected by agentctl when + // creating a new session. Users can add additional external MCP servers. + config := &models.AgentProfileMcpConfig{ + ProfileID: profile.ID, + Enabled: true, + Servers: map[string]interface{}{}, + Meta: map[string]interface{}{}, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + if err := c.repo.UpsertAgentProfileMcpConfig(ctx, config); err != nil { + c.logger.Warn("failed to create default MCP config for profile", + zap.String("profile_id", profile.ID), + zap.String("profile_name", profile.Name), + zap.Error(err)) + return nil + } + + c.logger.Info("created default MCP config for profile", + zap.String("profile_id", profile.ID), + zap.String("profile_name", profile.Name)) + return nil +} + +// GetAgentLogo returns the SVG logo bytes for the given agent and variant. +func (c *Controller) GetAgentLogo(ctx context.Context, agentName string, variant agents.LogoVariant) ([]byte, error) { + ag, ok := c.agentRegistry.Get(agentName) + if !ok { + return nil, ErrAgentNotFound + } + data := ag.Logo(variant) + if len(data) == 0 { + return nil, ErrLogoNotAvailable + } + return data, nil +} + +// resolvePermissionDefaults extracts the default permission values from agent settings. +func resolvePermissionDefaults(permSettings map[string]agents.PermissionSetting) (autoApprove, allowIndexing, skipPermissions bool) { + if permSettings == nil { + return + } + if s, exists := permSettings["auto_approve"]; exists { + autoApprove = s.Default + } + if s, exists := permSettings["allow_indexing"]; exists { + allowIndexing = s.Default + } + if s, exists := permSettings["dangerously_skip_permissions"]; exists { + skipPermissions = s.Default + } + return +} + +// resolveModelDisplayName returns the display name for a model ID from the model list, +// falling back to the model ID itself if not found. +func resolveModelDisplayName(modelList *agents.ModelList, modelID string) string { + if modelList != nil { + for _, m := range modelList.Models { + if m.ID == modelID { + return m.Name + } + } + } + return modelID +} + +// CommandPreviewRequest contains the draft settings for command preview +type CommandPreviewRequest struct { + Model string + PermissionSettings map[string]bool + CLIPassthrough bool +} + +// PreviewAgentCommand generates a preview of the CLI command that will be executed +func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, req CommandPreviewRequest) (*dto.CommandPreviewResponse, error) { + agentConfig, ok := c.agentRegistry.Get(agentName) + if !ok { + return nil, fmt.Errorf("agent type %q not found in registry", agentName) + } + + var cmd agents.Command + if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && req.CLIPassthrough { + cmd = ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ + Model: req.Model, + Prompt: "{prompt}", + PermissionValues: req.PermissionSettings, + }) + } else { + cmd = agentConfig.BuildCommand(agents.CommandOptions{ + Model: req.Model, + PermissionValues: req.PermissionSettings, + }) + } + + return &dto.CommandPreviewResponse{ + Supported: true, + Command: cmd.Args(), + CommandString: buildCommandString(cmd.Args()), + }, nil +} + +// FetchDynamicModels fetches models for an agent, optionally refreshing the cache +func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, refresh bool) (*dto.DynamicModelsResponse, error) { + ag, ok := c.agentRegistry.Get(agentName) + if !ok { + return nil, fmt.Errorf("agent %q not found", agentName) + } + + // Check cache unless refresh is requested + if !refresh { + if cached, ok := c.checkModelCache(agentName); ok { + return cached, nil + } + } + + // Fetch models from the agent + modelList, err := ag.ListModels(ctx) + if err != nil { + c.logger.Warn("model fetch failed", + zap.String("agent", agentName), + zap.Error(err)) + c.modelCache.Set(agentName, nil, err) + s := err.Error() + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Models: nil, + Cached: false, + Error: &s, + }, nil + } + + models := modelList.Models + + // Cache the result if dynamic models are supported + if modelList.SupportsDynamic { + c.modelCache.Set(agentName, models, nil) + cachedAt := time.Now() + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Models: modelsToDTO(models), + Cached: true, + CachedAt: &cachedAt, + }, nil + } + + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Models: modelsToDTO(models), + Cached: false, + }, nil +} + +// checkModelCache returns cached model response if available and valid. +func (c *Controller) checkModelCache(agentName string) (*dto.DynamicModelsResponse, bool) { + entry, exists := c.modelCache.Get(agentName) + if !exists || !entry.IsValid() { + return nil, false + } + cachedAt := entry.CachedAt + modelDTOs := modelsToDTO(entry.Models) + var errStr *string + if entry.Error != nil { + s := entry.Error.Error() + errStr = &s + } + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Models: modelDTOs, + Cached: true, + CachedAt: &cachedAt, + Error: errStr, + }, true +} + +// modelsToDTO converts agent models to DTOs. +func modelsToDTO(models []agents.Model) []dto.ModelEntryDTO { + dtos := make([]dto.ModelEntryDTO, 0, len(models)) + for _, m := range models { + dtos = append(dtos, dto.ModelEntryDTO{ + ID: m.ID, + Name: m.Name, + Provider: m.Provider, + ContextWindow: m.ContextWindow, + IsDefault: m.IsDefault, + Source: m.Source, + }) + } + return dtos +} diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go new file mode 100644 index 0000000000..e189f0fb57 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -0,0 +1,176 @@ +package controller + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/agent/discovery" + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" +) + +func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, error) { + agent, err := c.repo.GetAgent(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAgentNotFound + } + return nil, err + } + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + result := toAgentDTO(agent, profiles) + return &result, nil +} + +func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, error) { + agents, err := c.repo.ListAgents(ctx) + if err != nil { + return nil, err + } + payload := make([]dto.AgentDTO, 0, len(agents)) + for _, agent := range agents { + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + payload = append(payload, toAgentDTO(agent, profiles)) + } + return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil +} + +type CreateAgentRequest struct { + Name string + WorkspaceID *string + Profiles []CreateAgentProfileRequest +} + +type CreateAgentProfileRequest struct { + Name string + Model string + AutoApprove bool + DangerouslySkipPermissions bool +} + +func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) { + if req.Name == "" { + return nil, fmt.Errorf("name is required") + } + existing, err := c.repo.GetAgentByName(ctx, req.Name) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if err == nil && existing != nil { + return nil, fmt.Errorf("agent already configured: %s", req.Name) + } + discoveryResults, err := c.detectAgents(ctx) + if err != nil { + return nil, err + } + matched, err := c.findMatchedAvailability(req.Name, discoveryResults) + if err != nil { + return nil, err + } + agentConfig, agOk := c.agentRegistry.Get(req.Name) + if !agOk { + return nil, fmt.Errorf("unknown agent: %s", req.Name) + } + displayName, err := c.resolveDisplayName(agentConfig, req.Name) + if err != nil { + return nil, err + } + agent := &models.Agent{ + Name: matched.Name, + WorkspaceID: req.WorkspaceID, + SupportsMCP: matched.SupportsMCP, + MCPConfigPath: matched.MCPConfigPath, + } + if err := c.repo.CreateAgent(ctx, agent); err != nil { + return nil, err + } + profiles, err := c.createAgentProfiles(ctx, agent.ID, displayName, req.Profiles) + if err != nil { + return nil, err + } + result := toAgentDTO(agent, profiles) + return &result, nil +} + +func (c *Controller) findMatchedAvailability(name string, results []discovery.Availability) (*discovery.Availability, error) { + for _, result := range results { + if result.Name == name { + if !result.Available { + return nil, fmt.Errorf("agent not installed: %s", name) + } + r := result + return &r, nil + } + } + return nil, fmt.Errorf("unknown agent: %s", name) +} + +func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayName string, profileReqs []CreateAgentProfileRequest) ([]*models.AgentProfile, error) { + profiles := make([]*models.AgentProfile, 0, len(profileReqs)) + for _, profileReq := range profileReqs { + profile := &models.AgentProfile{ + AgentID: agentID, + Name: profileReq.Name, + AgentDisplayName: displayName, + Model: profileReq.Model, + AutoApprove: profileReq.AutoApprove, + DangerouslySkipPermissions: profileReq.DangerouslySkipPermissions, + } + if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { + return nil, err + } + profiles = append(profiles, profile) + } + return profiles, nil +} + +type UpdateAgentRequest struct { + ID string + WorkspaceID *string + SupportsMCP *bool + MCPConfigPath *string +} + +func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*dto.AgentDTO, error) { + agent, err := c.repo.GetAgent(ctx, req.ID) + if err != nil { + return nil, ErrAgentNotFound + } + if req.WorkspaceID != nil { + agent.WorkspaceID = req.WorkspaceID + } + if req.SupportsMCP != nil { + agent.SupportsMCP = *req.SupportsMCP + } + if req.MCPConfigPath != nil { + agent.MCPConfigPath = *req.MCPConfigPath + } + if err := c.repo.UpdateAgent(ctx, agent); err != nil { + return nil, err + } + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + result := toAgentDTO(agent, profiles) + return &result, nil +} + +func (c *Controller) DeleteAgent(ctx context.Context, id string) error { + if err := c.repo.DeleteAgent(ctx, id); err != nil { + if strings.Contains(err.Error(), "agent not found") { + return ErrAgentNotFound + } + return err + } + return nil +} diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go new file mode 100644 index 0000000000..6605c37c58 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -0,0 +1,357 @@ +package controller + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/discovery" + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" + "go.uber.org/zap" +) + +func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) { + results, err := c.detectAgents(ctx) + if err != nil { + return nil, err + } + payload := make([]dto.AgentDiscoveryDTO, 0, len(results)) + for _, result := range results { + payload = append(payload, dto.AgentDiscoveryDTO{ + Name: result.Name, + SupportsMCP: result.SupportsMCP, + MCPConfigPath: result.MCPConfigPath, + InstallationPaths: result.InstallationPaths, + Available: result.Available, + MatchedPath: result.MatchedPath, + }) + } + return &dto.ListDiscoveryResponse{Agents: payload, Total: len(payload)}, nil +} + +func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailableAgentsResponse, error) { + results, err := c.detectAgents(ctx) + if err != nil { + return nil, err + } + availabilityByName := make(map[string]discovery.Availability, len(results)) + for _, result := range results { + availabilityByName[result.Name] = result + } + + enabled := c.agentRegistry.ListEnabled() + now := time.Now().UTC() + payload := make([]dto.AvailableAgentDTO, 0, len(enabled)) + for _, ag := range enabled { + availability, ok := availabilityByName[ag.ID()] + if !ok { + availability = discovery.Availability{Name: ag.ID(), Available: false} + } + payload = append(payload, c.buildAvailableAgentDTO(ctx, ag, availability, now)) + } + return &dto.ListAvailableAgentsResponse{Agents: payload, Total: len(payload)}, nil +} + +func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent, availability discovery.Availability, now time.Time) dto.AvailableAgentDTO { + displayName := ag.DisplayName() + if displayName == "" { + displayName = ag.Name() + } + + var modelEntries []dto.ModelEntryDTO + var supportsDynamic bool + if modelList, err := ag.ListModels(ctx); err == nil && modelList != nil { + supportsDynamic = modelList.SupportsDynamic + modelEntries = make([]dto.ModelEntryDTO, 0, len(modelList.Models)) + for _, model := range modelList.Models { + modelEntries = append(modelEntries, dto.ModelEntryDTO{ + ID: model.ID, + Name: model.Name, + Provider: model.Provider, + ContextWindow: model.ContextWindow, + IsDefault: model.IsDefault, + }) + } + } + + var capabilities dto.AgentCapabilitiesDTO + if disc, discErr := ag.IsInstalled(ctx); discErr == nil && disc != nil { + capabilities = dto.AgentCapabilitiesDTO{ + SupportsSessionResume: disc.Capabilities.SupportsSessionResume, + SupportsShell: disc.Capabilities.SupportsShell, + SupportsWorkspaceOnly: disc.Capabilities.SupportsWorkspaceOnly, + } + } + + var permissionSettings map[string]dto.PermissionSettingDTO + if permSettings := ag.PermissionSettings(); permSettings != nil { + permissionSettings = make(map[string]dto.PermissionSettingDTO, len(permSettings)) + for key, setting := range permSettings { + permissionSettings[key] = dto.PermissionSettingDTO{ + Supported: setting.Supported, + Default: setting.Default, + Label: setting.Label, + Description: setting.Description, + ApplyMethod: setting.ApplyMethod, + CLIFlag: setting.CLIFlag, + CLIFlagValue: setting.CLIFlagValue, + } + } + } + + var passthroughConfig *dto.PassthroughConfigDTO + if ptAgent, ok := ag.(agents.PassthroughAgent); ok { + pt := ptAgent.PassthroughConfig() + passthroughConfig = &dto.PassthroughConfigDTO{ + Supported: pt.Supported, + Label: pt.Label, + Description: pt.Description, + } + } + + return dto.AvailableAgentDTO{ + Name: ag.ID(), + DisplayName: displayName, + SupportsMCP: availability.SupportsMCP, + MCPConfigPath: availability.MCPConfigPath, + InstallationPaths: availability.InstallationPaths, + Available: availability.Available, + MatchedPath: availability.MatchedPath, + Capabilities: capabilities, + ModelConfig: dto.ModelConfigDTO{ + DefaultModel: ag.DefaultModel(), + AvailableModels: modelEntries, + SupportsDynamicModels: supportsDynamic, + }, + PermissionSettings: permissionSettings, + PassthroughConfig: passthroughConfig, + UpdatedAt: now, + } +} + +func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { + results, err := c.detectAgents(ctx) + if err != nil { + return err + } + for _, result := range results { + if !result.Available { + continue + } + if err := c.syncAgentFromDiscovery(ctx, result); err != nil { + return err + } + } + return nil +} + +// profileSyncParams holds resolved parameters used when syncing agent profiles. +type profileSyncParams struct { + displayName string + defaultModel string + isPassthrough bool + autoApprove bool + allowIndexing bool + skipPermissions bool + modelList *agents.ModelList +} + +// updateExistingProfiles syncs non-user-modified profiles with current agent defaults. +func (c *Controller) updateExistingProfiles(ctx context.Context, profiles []*models.AgentProfile, p profileSyncParams) error { + for _, profile := range profiles { + if profile.UserModified { + continue + } + updated := false + if profile.AgentDisplayName != p.displayName { + profile.AgentDisplayName = p.displayName + updated = true + } + if profile.Model != p.defaultModel { + profile.Model = p.defaultModel + updated = true + } + resolvedName := resolveModelDisplayName(p.modelList, profile.Model) + if p.isPassthrough { + resolvedName = p.displayName + } + if profile.Name != resolvedName { + profile.Name = resolvedName + updated = true + } + if profile.AutoApprove != p.autoApprove { + profile.AutoApprove = p.autoApprove + updated = true + } + if profile.AllowIndexing != p.allowIndexing { + profile.AllowIndexing = p.allowIndexing + updated = true + } + if profile.DangerouslySkipPermissions != p.skipPermissions { + profile.DangerouslySkipPermissions = p.skipPermissions + updated = true + } + if p.isPassthrough && !profile.CLIPassthrough { + profile.CLIPassthrough = true + updated = true + } + if updated { + if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { + return err + } + } + } + return nil +} + +func (c *Controller) syncAgentFromDiscovery(ctx context.Context, result discovery.Availability) error { + agentConfig, ok := c.agentRegistry.Get(result.Name) + if !ok { + return fmt.Errorf("unknown agent: %s", result.Name) + } + displayName, err := c.resolveDisplayName(agentConfig, result.Name) + if err != nil { + return err + } + defaultModel, isPassthroughOnly, err := resolveDefaultModel(agentConfig, result.Name) + if err != nil { + return err + } + agent, err := c.upsertAgent(ctx, result) + if err != nil { + return err + } + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return err + } + p := c.buildProfileSyncParams(ctx, agentConfig, result.Name, displayName, defaultModel, isPassthroughOnly) + + if len(profiles) > 0 { + return c.updateExistingProfiles(ctx, profiles, p) + } + return c.createDefaultProfile(ctx, agent.ID, p) +} + +// resolveDisplayName returns the display name for an agent config, falling back to its +// internal name, and returns an error if no name can be determined. +func (c *Controller) resolveDisplayName(agentConfig agents.Agent, agentName string) (string, error) { + displayName := agentConfig.DisplayName() + if displayName == "" { + displayName = agentConfig.Name() + } + if displayName == "" { + return "", fmt.Errorf("unknown agent display name: %s", agentName) + } + return displayName, nil +} + +// buildProfileSyncParams assembles the parameters needed to create or update agent profiles. +func (c *Controller) buildProfileSyncParams( + ctx context.Context, + agentConfig agents.Agent, + agentName, displayName, defaultModel string, + isPassthroughOnly bool, +) profileSyncParams { + autoApprove, allowIndexing, skipPermissions := resolvePermissionDefaults(agentConfig.PermissionSettings()) + modelList, listErr := agentConfig.ListModels(ctx) + if listErr != nil { + c.logger.Warn("failed to list models during profile sync, using model ID as name", + zap.String("agent", agentName), zap.Error(listErr)) + } + return profileSyncParams{ + displayName: displayName, + defaultModel: defaultModel, + isPassthrough: isPassthroughOnly, + autoApprove: autoApprove, + allowIndexing: allowIndexing, + skipPermissions: skipPermissions, + modelList: modelList, + } +} + +// createDefaultProfile creates the initial agent profile when none exist for an agent. +func (c *Controller) createDefaultProfile(ctx context.Context, agentID string, p profileSyncParams) error { + profileName := resolveModelDisplayName(p.modelList, p.defaultModel) + if p.isPassthrough { + profileName = p.displayName + } + defaultProfile := &models.AgentProfile{ + AgentID: agentID, + Name: profileName, + Model: p.defaultModel, + AgentDisplayName: p.displayName, + AutoApprove: p.autoApprove, + AllowIndexing: p.allowIndexing, + DangerouslySkipPermissions: p.skipPermissions, + CLIPassthrough: p.isPassthrough, + } + return c.repo.CreateAgentProfile(ctx, defaultProfile) +} + +func resolveDefaultModel(agentConfig agents.Agent, name string) (string, bool, error) { + defaultModel := agentConfig.DefaultModel() + if defaultModel != "" { + return defaultModel, false, nil + } + if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && ptAgent.PassthroughConfig().Supported { + return "passthrough", true, nil + } + return "", false, fmt.Errorf("unknown agent default model: %s", name) +} + +func (c *Controller) upsertAgent(ctx context.Context, result discovery.Availability) (*models.Agent, error) { + agent, err := c.repo.GetAgentByName(ctx, result.Name) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if errors.Is(err, sql.ErrNoRows) || agent == nil { + agent = &models.Agent{ + Name: result.Name, + SupportsMCP: result.SupportsMCP, + MCPConfigPath: result.MCPConfigPath, + } + if err := c.repo.CreateAgent(ctx, agent); err != nil { + return nil, err + } + return agent, nil + } + updated := false + if agent.SupportsMCP != result.SupportsMCP { + agent.SupportsMCP = result.SupportsMCP + updated = true + } + if agent.MCPConfigPath != result.MCPConfigPath { + agent.MCPConfigPath = result.MCPConfigPath + updated = true + } + if updated { + if err := c.repo.UpdateAgent(ctx, agent); err != nil { + return nil, err + } + } + return agent, nil +} + +// detectAgents runs discovery and forces mock-agent available when enabled. +func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability, error) { + results, err := c.discovery.Detect(ctx) + if err != nil { + return nil, err + } + // Force mock-agent as available when enabled (skip file-presence discovery) + agentConfig, ok := c.agentRegistry.Get("mock-agent") + if ok && agentConfig.Enabled() { + for i := range results { + if results[i].Name == "mock-agent" { + results[i].Available = true + break + } + } + } + return results, nil +} diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 2cffe898b5..8ad56e9f7a 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -2,19 +2,13 @@ package controller import ( "context" - "database/sql" "errors" - "fmt" "strings" - "time" - "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/modelfetcher" - "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" @@ -69,868 +63,3 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, logger: log.WithFields(zap.String("component", "agent-settings-controller")), } } - -// EnsureDefaultMcpConfig ensures all agent profiles that support MCP have -// MCP enabled by default. The kandev MCP server is automatically injected -// by agentctl at session creation time, so profiles don't need to explicitly -// configure it. -func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error { - agents, err := c.repo.ListAgents(ctx) - if err != nil { - return err - } - - for _, agent := range agents { - if !agent.SupportsMCP { - continue - } - - profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) - if err != nil { - return err - } - - for _, profile := range profiles { - // Check if MCP config already exists - existingConfig, err := c.repo.GetAgentProfileMcpConfig(ctx, profile.ID) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return err - } - - // Skip if config already exists (don't overwrite user settings) - if existingConfig != nil { - continue - } - - // Create default MCP config with MCP enabled but no servers configured. - // The kandev MCP server is automatically injected by agentctl when - // creating a new session. Users can add additional external MCP servers. - config := &models.AgentProfileMcpConfig{ - ProfileID: profile.ID, - Enabled: true, - Servers: map[string]interface{}{}, - Meta: map[string]interface{}{}, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - - if err := c.repo.UpsertAgentProfileMcpConfig(ctx, config); err != nil { - c.logger.Warn("failed to create default MCP config for profile", - zap.String("profile_id", profile.ID), - zap.String("profile_name", profile.Name), - zap.Error(err)) - continue - } - - c.logger.Info("created default MCP config for profile", - zap.String("profile_id", profile.ID), - zap.String("profile_name", profile.Name)) - } - } - - return nil -} - -func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) { - results, err := c.detectAgents(ctx) - if err != nil { - return nil, err - } - payload := make([]dto.AgentDiscoveryDTO, 0, len(results)) - for _, result := range results { - payload = append(payload, dto.AgentDiscoveryDTO{ - Name: result.Name, - SupportsMCP: result.SupportsMCP, - MCPConfigPath: result.MCPConfigPath, - InstallationPaths: result.InstallationPaths, - Available: result.Available, - MatchedPath: result.MatchedPath, - }) - } - return &dto.ListDiscoveryResponse{Agents: payload, Total: len(payload)}, nil -} - -func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailableAgentsResponse, error) { - results, err := c.detectAgents(ctx) - if err != nil { - return nil, err - } - availabilityByName := make(map[string]discovery.Availability, len(results)) - for _, result := range results { - availabilityByName[result.Name] = result - } - - enabled := c.agentRegistry.ListEnabled() - now := time.Now().UTC() - payload := make([]dto.AvailableAgentDTO, 0, len(enabled)) - for _, ag := range enabled { - availability, ok := availabilityByName[ag.ID()] - if !ok { - availability = discovery.Availability{ - Name: ag.ID(), - Available: false, - } - } - - displayName := ag.DisplayName() - if displayName == "" { - displayName = ag.Name() - } - - // Get models from the agent - var modelEntries []dto.ModelEntryDTO - var supportsDynamic bool - modelList, err := ag.ListModels(ctx) - if err == nil && modelList != nil { - supportsDynamic = modelList.SupportsDynamic - modelEntries = make([]dto.ModelEntryDTO, 0, len(modelList.Models)) - for _, model := range modelList.Models { - modelEntries = append(modelEntries, dto.ModelEntryDTO{ - ID: model.ID, - Name: model.Name, - Provider: model.Provider, - ContextWindow: model.ContextWindow, - IsDefault: model.IsDefault, - }) - } - } - - // Get discovery result for capabilities - disc, discErr := ag.IsInstalled(ctx) - var capabilities dto.AgentCapabilitiesDTO - if discErr == nil && disc != nil { - capabilities = dto.AgentCapabilitiesDTO{ - SupportsSessionResume: disc.Capabilities.SupportsSessionResume, - SupportsShell: disc.Capabilities.SupportsShell, - SupportsWorkspaceOnly: disc.Capabilities.SupportsWorkspaceOnly, - } - } - - // Convert permission settings - var permissionSettings map[string]dto.PermissionSettingDTO - permSettings := ag.PermissionSettings() - if permSettings != nil { - permissionSettings = make(map[string]dto.PermissionSettingDTO, len(permSettings)) - for key, setting := range permSettings { - permissionSettings[key] = dto.PermissionSettingDTO{ - Supported: setting.Supported, - Default: setting.Default, - Label: setting.Label, - Description: setting.Description, - ApplyMethod: setting.ApplyMethod, - CLIFlag: setting.CLIFlag, - CLIFlagValue: setting.CLIFlagValue, - } - } - } - - // Convert passthrough config - var passthroughConfig *dto.PassthroughConfigDTO - if ptAgent, ok := ag.(agents.PassthroughAgent); ok { - pt := ptAgent.PassthroughConfig() - passthroughConfig = &dto.PassthroughConfigDTO{ - Supported: pt.Supported, - Label: pt.Label, - Description: pt.Description, - } - } - - payload = append(payload, dto.AvailableAgentDTO{ - Name: ag.ID(), - DisplayName: displayName, - SupportsMCP: availability.SupportsMCP, - MCPConfigPath: availability.MCPConfigPath, - InstallationPaths: availability.InstallationPaths, - Available: availability.Available, - MatchedPath: availability.MatchedPath, - Capabilities: capabilities, - ModelConfig: dto.ModelConfigDTO{ - DefaultModel: ag.DefaultModel(), - AvailableModels: modelEntries, - SupportsDynamicModels: supportsDynamic, - }, - PermissionSettings: permissionSettings, - PassthroughConfig: passthroughConfig, - UpdatedAt: now, - }) - } - return &dto.ListAvailableAgentsResponse{Agents: payload, Total: len(payload)}, nil -} - -func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, error) { - agents, err := c.repo.ListAgents(ctx) - if err != nil { - return nil, err - } - payload := make([]dto.AgentDTO, 0, len(agents)) - for _, agent := range agents { - profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) - if err != nil { - return nil, err - } - payload = append(payload, toAgentDTO(agent, profiles)) - } - return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil -} - -func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { - results, err := c.detectAgents(ctx) - if err != nil { - return err - } - for _, result := range results { - if !result.Available { - continue - } - agentConfig, ok := c.agentRegistry.Get(result.Name) - if !ok { - return fmt.Errorf("unknown agent: %s", result.Name) - } - displayName := agentConfig.DisplayName() - if displayName == "" { - displayName = agentConfig.Name() - } - if displayName == "" { - return fmt.Errorf("unknown agent display name: %s", result.Name) - } - defaultModel := agentConfig.DefaultModel() - isPassthroughOnly := false - if defaultModel == "" { - if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && ptAgent.PassthroughConfig().Supported { - isPassthroughOnly = true - defaultModel = "passthrough" - } else { - return fmt.Errorf("unknown agent default model: %s", result.Name) - } - } - agent, err := c.repo.GetAgentByName(ctx, result.Name) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return err - } - if errors.Is(err, sql.ErrNoRows) || agent == nil { - agent = &models.Agent{ - Name: result.Name, - SupportsMCP: result.SupportsMCP, - MCPConfigPath: result.MCPConfigPath, - } - if err := c.repo.CreateAgent(ctx, agent); err != nil { - return err - } - } else { - updated := false - if agent.SupportsMCP != result.SupportsMCP { - agent.SupportsMCP = result.SupportsMCP - updated = true - } - if agent.MCPConfigPath != result.MCPConfigPath { - agent.MCPConfigPath = result.MCPConfigPath - updated = true - } - if updated { - if err := c.repo.UpdateAgent(ctx, agent); err != nil { - return err - } - } - } - profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) - if err != nil { - return err - } - autoApprove, allowIndexing, skipPermissions := resolvePermissionDefaults(agentConfig.PermissionSettings()) - - // Fetch model list once per agent (not per profile) to resolve display names - modelList, listErr := agentConfig.ListModels(ctx) - if listErr != nil { - c.logger.Warn("failed to list models during profile sync, using model ID as name", - zap.String("agent", result.Name), zap.Error(listErr)) - } - - if len(profiles) > 0 { - for _, profile := range profiles { - if profile.UserModified { - continue - } - updated := false - - if profile.AgentDisplayName != displayName { - profile.AgentDisplayName = displayName - updated = true - } - if profile.Model != defaultModel { - profile.Model = defaultModel - updated = true - } - - // Re-resolve profile name from model display name - resolvedName := resolveModelDisplayName(modelList, profile.Model) - if isPassthroughOnly { - resolvedName = displayName - } - if profile.Name != resolvedName { - profile.Name = resolvedName - updated = true - } - - if profile.AutoApprove != autoApprove { - profile.AutoApprove = autoApprove - updated = true - } - if profile.AllowIndexing != allowIndexing { - profile.AllowIndexing = allowIndexing - updated = true - } - if profile.DangerouslySkipPermissions != skipPermissions { - profile.DangerouslySkipPermissions = skipPermissions - updated = true - } - if isPassthroughOnly && !profile.CLIPassthrough { - profile.CLIPassthrough = true - updated = true - } - - if updated { - if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { - return err - } - } - } - continue - } - - profileName := resolveModelDisplayName(modelList, defaultModel) - if isPassthroughOnly { - profileName = displayName - } - defaultProfile := &models.AgentProfile{ - AgentID: agent.ID, - Name: profileName, - Model: defaultModel, - AgentDisplayName: displayName, - AutoApprove: autoApprove, - AllowIndexing: allowIndexing, - DangerouslySkipPermissions: skipPermissions, - CLIPassthrough: isPassthroughOnly, - } - if err := c.repo.CreateAgentProfile(ctx, defaultProfile); err != nil { - return err - } - } - return nil -} - -func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, error) { - agent, err := c.repo.GetAgent(ctx, id) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrAgentNotFound - } - return nil, err - } - profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) - if err != nil { - return nil, err - } - result := toAgentDTO(agent, profiles) - return &result, nil -} - -type UpdateAgentProfileMcpConfigRequest struct { - Enabled bool - Servers map[string]mcpconfig.ServerDef - Meta map[string]any -} - -func (c *Controller) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*dto.AgentProfileMcpConfigDTO, error) { - config, err := c.mcpService.GetConfigByProfileID(ctx, profileID) - if err != nil { - if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) { - return nil, ErrAgentProfileNotFound - } - if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) { - return nil, ErrAgentMcpUnsupported - } - return nil, err - } - return &dto.AgentProfileMcpConfigDTO{ - ProfileID: config.ProfileID, - Enabled: config.Enabled, - Servers: config.Servers, - Meta: config.Meta, - }, nil -} - -func (c *Controller) UpdateAgentProfileMcpConfig(ctx context.Context, profileID string, req UpdateAgentProfileMcpConfigRequest) (*dto.AgentProfileMcpConfigDTO, error) { - config, err := c.mcpService.UpsertConfigByProfileID(ctx, profileID, &mcpconfig.ProfileConfig{ - Enabled: req.Enabled, - Servers: req.Servers, - Meta: req.Meta, - }) - if err != nil { - if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) { - return nil, ErrAgentProfileNotFound - } - if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) { - return nil, ErrAgentMcpUnsupported - } - return nil, err - } - return &dto.AgentProfileMcpConfigDTO{ - ProfileID: config.ProfileID, - Enabled: config.Enabled, - Servers: config.Servers, - Meta: config.Meta, - }, nil -} - -type CreateAgentRequest struct { - Name string - WorkspaceID *string - Profiles []CreateAgentProfileRequest -} - -type CreateAgentProfileRequest struct { - Name string - Model string - AutoApprove bool - DangerouslySkipPermissions bool -} - -func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) { - if req.Name == "" { - return nil, fmt.Errorf("name is required") - } - existing, err := c.repo.GetAgentByName(ctx, req.Name) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - if err == nil && existing != nil { - return nil, fmt.Errorf("agent already configured: %s", req.Name) - } - discoveryResults, err := c.detectAgents(ctx) - if err != nil { - return nil, err - } - var matched *discovery.Availability - for _, result := range discoveryResults { - if result.Name == req.Name { - matched = &result - break - } - } - if matched == nil { - return nil, fmt.Errorf("unknown agent: %s", req.Name) - } - if !matched.Available { - return nil, fmt.Errorf("agent not installed: %s", req.Name) - } - agentConfig, agOk := c.agentRegistry.Get(req.Name) - if !agOk { - return nil, fmt.Errorf("unknown agent: %s", req.Name) - } - displayName := agentConfig.DisplayName() - if displayName == "" { - displayName = agentConfig.Name() - } - if displayName == "" { - return nil, fmt.Errorf("unknown agent display name: %s", req.Name) - } - agent := &models.Agent{ - Name: matched.Name, - WorkspaceID: req.WorkspaceID, - SupportsMCP: matched.SupportsMCP, - MCPConfigPath: matched.MCPConfigPath, - } - if err := c.repo.CreateAgent(ctx, agent); err != nil { - return nil, err - } - profiles := make([]*models.AgentProfile, 0, len(req.Profiles)) - for _, profileReq := range req.Profiles { - profile := &models.AgentProfile{ - AgentID: agent.ID, - Name: profileReq.Name, - AgentDisplayName: displayName, - Model: profileReq.Model, - AutoApprove: profileReq.AutoApprove, - DangerouslySkipPermissions: profileReq.DangerouslySkipPermissions, - } - if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { - return nil, err - } - profiles = append(profiles, profile) - } - result := toAgentDTO(agent, profiles) - return &result, nil -} - -type UpdateAgentRequest struct { - ID string - WorkspaceID *string - SupportsMCP *bool - MCPConfigPath *string -} - -func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*dto.AgentDTO, error) { - agent, err := c.repo.GetAgent(ctx, req.ID) - if err != nil { - return nil, ErrAgentNotFound - } - if req.WorkspaceID != nil { - agent.WorkspaceID = req.WorkspaceID - } - if req.SupportsMCP != nil { - agent.SupportsMCP = *req.SupportsMCP - } - if req.MCPConfigPath != nil { - agent.MCPConfigPath = *req.MCPConfigPath - } - if err := c.repo.UpdateAgent(ctx, agent); err != nil { - return nil, err - } - profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) - if err != nil { - return nil, err - } - result := toAgentDTO(agent, profiles) - return &result, nil -} - -func (c *Controller) DeleteAgent(ctx context.Context, id string) error { - if err := c.repo.DeleteAgent(ctx, id); err != nil { - if strings.Contains(err.Error(), "agent not found") { - return ErrAgentNotFound - } - return err - } - return nil -} - -type CreateProfileRequest struct { - AgentID string - Name string - Model string - AutoApprove bool - DangerouslySkipPermissions bool - AllowIndexing bool - CLIPassthrough bool -} - -func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) { - // Validate that model is provided - if strings.TrimSpace(req.Model) == "" { - return nil, ErrModelRequired - } - agent, err := c.repo.GetAgent(ctx, req.AgentID) - if err != nil { - return nil, err - } - agentConfig, agOk := c.agentRegistry.Get(agent.Name) - if !agOk { - return nil, fmt.Errorf("unknown agent: %s", agent.Name) - } - displayName := agentConfig.DisplayName() - if displayName == "" { - displayName = agentConfig.Name() - } - if displayName == "" { - return nil, fmt.Errorf("unknown agent display name: %s", agent.Name) - } - profile := &models.AgentProfile{ - AgentID: req.AgentID, - Name: req.Name, - AgentDisplayName: displayName, - Model: req.Model, - AutoApprove: req.AutoApprove, - DangerouslySkipPermissions: req.DangerouslySkipPermissions, - AllowIndexing: req.AllowIndexing, - CLIPassthrough: req.CLIPassthrough, - UserModified: true, - } - if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { - return nil, err - } - result := toProfileDTO(profile) - return &result, nil -} - -type UpdateProfileRequest struct { - ID string - Name *string - Model *string - AutoApprove *bool - DangerouslySkipPermissions *bool - AllowIndexing *bool - CLIPassthrough *bool -} - -func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) { - profile, err := c.repo.GetAgentProfile(ctx, req.ID) - if err != nil { - return nil, ErrAgentProfileNotFound - } - if req.Name != nil { - profile.Name = *req.Name - } - if req.Model != nil { - // Validate that model is not empty - if strings.TrimSpace(*req.Model) == "" { - return nil, ErrModelRequired - } - profile.Model = *req.Model - } - if req.AutoApprove != nil { - profile.AutoApprove = *req.AutoApprove - } - if req.DangerouslySkipPermissions != nil { - profile.DangerouslySkipPermissions = *req.DangerouslySkipPermissions - } - if req.AllowIndexing != nil { - profile.AllowIndexing = *req.AllowIndexing - } - if req.CLIPassthrough != nil { - profile.CLIPassthrough = *req.CLIPassthrough - } - profile.UserModified = true - if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { - return nil, err - } - result := toProfileDTO(profile) - return &result, nil -} - -func (c *Controller) DeleteProfile(ctx context.Context, id string) (*dto.AgentProfileDTO, error) { - profile, err := c.repo.GetAgentProfile(ctx, id) - if err != nil { - if strings.Contains(err.Error(), "agent profile not found") { - return nil, ErrAgentProfileNotFound - } - return nil, err - } - if c.sessionChecker != nil { - active, err := c.sessionChecker.HasActiveTaskSessionsByAgentProfile(ctx, id) - if err != nil { - return nil, err - } - if active { - return nil, ErrAgentProfileInUse - } - } - if err := c.repo.DeleteAgentProfile(ctx, id); err != nil { - if strings.Contains(err.Error(), "agent profile not found") { - return nil, ErrAgentProfileNotFound - } - return nil, err - } - result := toProfileDTO(profile) - return &result, nil -} - -func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO { - profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles)) - for _, profile := range profiles { - profileDTOs = append(profileDTOs, toProfileDTO(profile)) - } - return dto.AgentDTO{ - ID: agent.ID, - Name: agent.Name, - WorkspaceID: agent.WorkspaceID, - SupportsMCP: agent.SupportsMCP, - MCPConfigPath: agent.MCPConfigPath, - Profiles: profileDTOs, - CreatedAt: agent.CreatedAt, - UpdatedAt: agent.UpdatedAt, - } -} - -func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { - return dto.AgentProfileDTO{ - ID: profile.ID, - AgentID: profile.AgentID, - Name: profile.Name, - AgentDisplayName: profile.AgentDisplayName, - Model: profile.Model, - AutoApprove: profile.AutoApprove, - DangerouslySkipPermissions: profile.DangerouslySkipPermissions, - AllowIndexing: profile.AllowIndexing, - CLIPassthrough: profile.CLIPassthrough, - UserModified: profile.UserModified, - CreatedAt: profile.CreatedAt, - UpdatedAt: profile.UpdatedAt, - } -} - -// GetAgentLogo returns the SVG logo bytes for the given agent and variant. -func (c *Controller) GetAgentLogo(ctx context.Context, agentName string, variant agents.LogoVariant) ([]byte, error) { - ag, ok := c.agentRegistry.Get(agentName) - if !ok { - return nil, ErrAgentNotFound - } - data := ag.Logo(variant) - if len(data) == 0 { - return nil, ErrLogoNotAvailable - } - return data, nil -} - -// resolvePermissionDefaults extracts the default permission values from agent settings. -func resolvePermissionDefaults(permSettings map[string]agents.PermissionSetting) (autoApprove, allowIndexing, skipPermissions bool) { - if permSettings == nil { - return - } - if s, exists := permSettings["auto_approve"]; exists { - autoApprove = s.Default - } - if s, exists := permSettings["allow_indexing"]; exists { - allowIndexing = s.Default - } - if s, exists := permSettings["dangerously_skip_permissions"]; exists { - skipPermissions = s.Default - } - return -} - -// resolveModelDisplayName returns the display name for a model ID from the model list, -// falling back to the model ID itself if not found. -func resolveModelDisplayName(modelList *agents.ModelList, modelID string) string { - if modelList != nil { - for _, m := range modelList.Models { - if m.ID == modelID { - return m.Name - } - } - } - return modelID -} - -// detectAgents runs discovery and forces mock-agent available when enabled. -func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability, error) { - results, err := c.discovery.Detect(ctx) - if err != nil { - return nil, err - } - // Force mock-agent as available when enabled (skip file-presence discovery) - agentConfig, ok := c.agentRegistry.Get("mock-agent") - if ok && agentConfig.Enabled() { - for i := range results { - if results[i].Name == "mock-agent" { - results[i].Available = true - break - } - } - } - return results, nil -} - -// CommandPreviewRequest contains the draft settings for command preview -type CommandPreviewRequest struct { - Model string - PermissionSettings map[string]bool - CLIPassthrough bool -} - -// PreviewAgentCommand generates a preview of the CLI command that will be executed -func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, req CommandPreviewRequest) (*dto.CommandPreviewResponse, error) { - agentConfig, ok := c.agentRegistry.Get(agentName) - if !ok { - return nil, fmt.Errorf("agent type %q not found in registry", agentName) - } - - var cmd agents.Command - if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && req.CLIPassthrough { - cmd = ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ - Model: req.Model, - Prompt: "{prompt}", - PermissionValues: req.PermissionSettings, - }) - } else { - cmd = agentConfig.BuildCommand(agents.CommandOptions{ - Model: req.Model, - PermissionValues: req.PermissionSettings, - }) - } - - return &dto.CommandPreviewResponse{ - Supported: true, - Command: cmd.Args(), - CommandString: buildCommandString(cmd.Args()), - }, nil -} - -// FetchDynamicModels fetches models for an agent, optionally refreshing the cache -func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, refresh bool) (*dto.DynamicModelsResponse, error) { - ag, ok := c.agentRegistry.Get(agentName) - if !ok { - return nil, fmt.Errorf("agent %q not found", agentName) - } - - // Check cache unless refresh is requested - if !refresh { - if entry, exists := c.modelCache.Get(agentName); exists && entry.IsValid() { - cachedAt := entry.CachedAt - modelDTOs := modelsToDTO(entry.Models) - var errStr *string - if entry.Error != nil { - s := entry.Error.Error() - errStr = &s - } - return &dto.DynamicModelsResponse{ - AgentName: agentName, - Models: modelDTOs, - Cached: true, - CachedAt: &cachedAt, - Error: errStr, - }, nil - } - } - - // Fetch models from the agent - modelList, err := ag.ListModels(ctx) - if err != nil { - c.logger.Warn("model fetch failed", - zap.String("agent", agentName), - zap.Error(err)) - c.modelCache.Set(agentName, nil, err) - s := err.Error() - return &dto.DynamicModelsResponse{ - AgentName: agentName, - Models: nil, - Cached: false, - Error: &s, - }, nil - } - - models := modelList.Models - - // Cache the result if dynamic models are supported - if modelList.SupportsDynamic { - c.modelCache.Set(agentName, models, nil) - cachedAt := time.Now() - return &dto.DynamicModelsResponse{ - AgentName: agentName, - Models: modelsToDTO(models), - Cached: true, - CachedAt: &cachedAt, - }, nil - } - - return &dto.DynamicModelsResponse{ - AgentName: agentName, - Models: modelsToDTO(models), - Cached: false, - }, nil -} - -// modelsToDTO converts agent models to DTOs. -func modelsToDTO(models []agents.Model) []dto.ModelEntryDTO { - dtos := make([]dto.ModelEntryDTO, 0, len(models)) - for _, m := range models { - dtos = append(dtos, dto.ModelEntryDTO{ - ID: m.ID, - Name: m.Name, - Provider: m.Provider, - ContextWindow: m.ContextWindow, - IsDefault: m.IsDefault, - Source: m.Source, - }) - } - return dtos -} diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go new file mode 100644 index 0000000000..379197a4a4 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -0,0 +1,161 @@ +package controller + +import ( + "context" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" +) + +type CreateProfileRequest struct { + AgentID string + Name string + Model string + AutoApprove bool + DangerouslySkipPermissions bool + AllowIndexing bool + CLIPassthrough bool +} + +func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) { + // Validate that model is provided + if strings.TrimSpace(req.Model) == "" { + return nil, ErrModelRequired + } + agent, err := c.repo.GetAgent(ctx, req.AgentID) + if err != nil { + return nil, err + } + agentConfig, agOk := c.agentRegistry.Get(agent.Name) + if !agOk { + return nil, fmt.Errorf("unknown agent: %s", agent.Name) + } + displayName, err := c.resolveDisplayName(agentConfig, agent.Name) + if err != nil { + return nil, err + } + profile := &models.AgentProfile{ + AgentID: req.AgentID, + Name: req.Name, + AgentDisplayName: displayName, + Model: req.Model, + AutoApprove: req.AutoApprove, + DangerouslySkipPermissions: req.DangerouslySkipPermissions, + AllowIndexing: req.AllowIndexing, + CLIPassthrough: req.CLIPassthrough, + UserModified: true, + } + if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { + return nil, err + } + result := toProfileDTO(profile) + return &result, nil +} + +type UpdateProfileRequest struct { + ID string + Name *string + Model *string + AutoApprove *bool + DangerouslySkipPermissions *bool + AllowIndexing *bool + CLIPassthrough *bool +} + +func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) { + profile, err := c.repo.GetAgentProfile(ctx, req.ID) + if err != nil { + return nil, ErrAgentProfileNotFound + } + if req.Name != nil { + profile.Name = *req.Name + } + if req.Model != nil { + // Validate that model is not empty + if strings.TrimSpace(*req.Model) == "" { + return nil, ErrModelRequired + } + profile.Model = *req.Model + } + if req.AutoApprove != nil { + profile.AutoApprove = *req.AutoApprove + } + if req.DangerouslySkipPermissions != nil { + profile.DangerouslySkipPermissions = *req.DangerouslySkipPermissions + } + if req.AllowIndexing != nil { + profile.AllowIndexing = *req.AllowIndexing + } + if req.CLIPassthrough != nil { + profile.CLIPassthrough = *req.CLIPassthrough + } + profile.UserModified = true + if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { + return nil, err + } + result := toProfileDTO(profile) + return &result, nil +} + +func (c *Controller) DeleteProfile(ctx context.Context, id string) (*dto.AgentProfileDTO, error) { + profile, err := c.repo.GetAgentProfile(ctx, id) + if err != nil { + if strings.Contains(err.Error(), "agent profile not found") { + return nil, ErrAgentProfileNotFound + } + return nil, err + } + if c.sessionChecker != nil { + active, err := c.sessionChecker.HasActiveTaskSessionsByAgentProfile(ctx, id) + if err != nil { + return nil, err + } + if active { + return nil, ErrAgentProfileInUse + } + } + if err := c.repo.DeleteAgentProfile(ctx, id); err != nil { + if strings.Contains(err.Error(), "agent profile not found") { + return nil, ErrAgentProfileNotFound + } + return nil, err + } + result := toProfileDTO(profile) + return &result, nil +} + +func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO { + profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles)) + for _, profile := range profiles { + profileDTOs = append(profileDTOs, toProfileDTO(profile)) + } + return dto.AgentDTO{ + ID: agent.ID, + Name: agent.Name, + WorkspaceID: agent.WorkspaceID, + SupportsMCP: agent.SupportsMCP, + MCPConfigPath: agent.MCPConfigPath, + Profiles: profileDTOs, + CreatedAt: agent.CreatedAt, + UpdatedAt: agent.UpdatedAt, + } +} + +func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { + return dto.AgentProfileDTO{ + ID: profile.ID, + AgentID: profile.AgentID, + Name: profile.Name, + AgentDisplayName: profile.AgentDisplayName, + Model: profile.Model, + AutoApprove: profile.AutoApprove, + DangerouslySkipPermissions: profile.DangerouslySkipPermissions, + AllowIndexing: profile.AllowIndexing, + CLIPassthrough: profile.CLIPassthrough, + UserModified: profile.UserModified, + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, + } +} diff --git a/apps/backend/internal/agentctl/client/agent.go b/apps/backend/internal/agentctl/client/agent.go index d7dac026e0..4a3a95c3ba 100644 --- a/apps/backend/internal/agentctl/client/agent.go +++ b/apps/backend/internal/agentctl/client/agent.go @@ -207,83 +207,96 @@ func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent), mc return conn.WriteMessage(websocket.TextMessage, data) } - // Read messages in a goroutine - go func() { - var lastErr error - defer func() { - // Clean up pending requests before signaling disconnect - c.cleanupPendingRequests() + go c.readUpdatesStream(ctx, conn, handler, mcpHandler, onDisconnect, writeMessage) - c.mu.Lock() - c.agentStreamConn = nil - c.mu.Unlock() - if err := conn.Close(); err != nil { - c.logger.Debug("failed to close updates websocket", zap.Error(err)) - } - // Signal disconnect to caller - if onDisconnect != nil { - onDisconnect(lastErr) - } - }() + return nil +} - for { - _, message, err := conn.ReadMessage() - if err != nil { - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - c.logger.Info("updates stream closed normally") - // Normal close — don't report as disconnect error - } else { - c.logger.Debug("updates stream error", zap.Error(err)) - lastErr = err - } - return - } +// readUpdatesStream is the read loop for the agent updates WebSocket stream. +func (c *Client) readUpdatesStream( + ctx context.Context, + conn *websocket.Conn, + handler func(AgentEvent), + mcpHandler MCPHandler, + onDisconnect func(err error), + writeMessage func([]byte) error, +) { + var lastErr error + defer func() { + // Clean up pending requests before signaling disconnect + c.cleanupPendingRequests() - // Try to parse as ws.Message to check message type - var wsMsg ws.Message - if err := json.Unmarshal(message, &wsMsg); err == nil { - // Check if this is a response/error to a pending request - if (wsMsg.Type == ws.MessageTypeResponse || wsMsg.Type == ws.MessageTypeError) && c.resolvePendingRequest(&wsMsg) { - continue - } - - // This is an MCP request - dispatch it - if wsMsg.Type == ws.MessageTypeRequest { - if mcpHandler != nil { - go func(msg ws.Message) { - resp, err := mcpHandler.Dispatch(ctx, &msg) - if err != nil { - c.logger.Error("MCP dispatch error", zap.Error(err)) - resp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) - } - if resp != nil { - data, err := json.Marshal(resp) - if err != nil { - c.logger.Error("failed to marshal MCP response", zap.Error(err)) - return - } - if err := writeMessage(data); err != nil { - c.logger.Debug("failed to write MCP response", zap.Error(err)) - } - } - }(wsMsg) - } - continue - } - } - - // Not a ws.Message request/response, parse as agent event - var event AgentEvent - if err := json.Unmarshal(message, &event); err != nil { - c.logger.Warn("failed to parse agent event", zap.Error(err)) - continue - } - - handler(event) + c.mu.Lock() + c.agentStreamConn = nil + c.mu.Unlock() + if err := conn.Close(); err != nil { + c.logger.Debug("failed to close updates websocket", zap.Error(err)) + } + // Signal disconnect to caller + if onDisconnect != nil { + onDisconnect(lastErr) } }() - return nil + for { + _, message, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + c.logger.Info("updates stream closed normally") + // Normal close — don't report as disconnect error + } else { + c.logger.Debug("updates stream error", zap.Error(err)) + lastErr = err + } + return + } + + // Try to parse as ws.Message to check message type + var wsMsg ws.Message + if err := json.Unmarshal(message, &wsMsg); err == nil { + // Check if this is a response/error to a pending request + if (wsMsg.Type == ws.MessageTypeResponse || wsMsg.Type == ws.MessageTypeError) && c.resolvePendingRequest(&wsMsg) { + continue + } + + // This is an MCP request - dispatch it + if wsMsg.Type == ws.MessageTypeRequest { + if mcpHandler != nil { + go c.dispatchMCPRequest(ctx, wsMsg, mcpHandler, writeMessage) + } + continue + } + } + + // Not a ws.Message request/response, parse as agent event + var event AgentEvent + if err := json.Unmarshal(message, &event); err != nil { + c.logger.Warn("failed to parse agent event", zap.Error(err)) + continue + } + + handler(event) + } +} + +// dispatchMCPRequest handles an incoming MCP request message, dispatches it to the handler, +// and writes the response back over the stream. +func (c *Client) dispatchMCPRequest(ctx context.Context, msg ws.Message, mcpHandler MCPHandler, writeMessage func([]byte) error) { + resp, err := mcpHandler.Dispatch(ctx, &msg) + if err != nil { + c.logger.Error("MCP dispatch error", zap.Error(err)) + resp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + if resp != nil { + data, err := json.Marshal(resp) + if err != nil { + c.logger.Error("failed to marshal MCP response", zap.Error(err)) + return + } + if err := writeMessage(data); err != nil { + c.logger.Debug("failed to write MCP response", zap.Error(err)) + } + } } // CloseUpdatesStream closes the agent events stream connection. diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index a94c54365f..b9487ab563 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -7,14 +7,11 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "sync" "time" - "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -286,763 +283,12 @@ type ( 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"` -} - -// FileSearchResponse represents a response with matching files -type FileSearchResponse struct { - Files []string `json:"files"` - 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) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to request file tree: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logger.Debug("failed to close file tree response body", zap.Error(err)) - } - }() - - var response FileTreeResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if response.Error != "" { - return nil, fmt.Errorf("file tree error: %s", response.Error) - } - - return &response, nil -} - -// SearchFiles searches for files matching the query via HTTP GET -func (c *Client) SearchFiles(ctx context.Context, query string, limit int) (*FileSearchResponse, error) { - reqURL := fmt.Sprintf("%s/api/v1/workspace/search?q=%s&limit=%d", c.baseURL, url.QueryEscape(query), limit) - - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to search files: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logger.Debug("failed to close file search response body", zap.Error(err)) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("file search failed with status %d", resp.StatusCode) - } - - var result FileSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &result, nil -} - -// RequestFileContent requests file content via HTTP GET -func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileContentResponse, error) { - reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content?path=%s", c.baseURL, path) - - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to request file content: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logger.Debug("failed to close file content response body", zap.Error(err)) - } - }() - - var response FileContentResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if response.Error != "" { - return nil, fmt.Errorf("file content error: %s", response.Error) - } - - return &response, nil -} - -// ApplyFileDiff applies a unified diff to a file via HTTP POST -func (c *Client) ApplyFileDiff(ctx context.Context, path string, diff string, originalHash string) (*streams.FileUpdateResponse, error) { - reqBody := streams.FileUpdateRequest{ - Path: path, - Diff: diff, - OriginalHash: originalHash, - } - - bodyBytes, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content", c.baseURL) - req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(bodyBytes)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to apply file diff: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logger.Debug("failed to close file update response body", zap.Error(err)) - } - }() - - var response streams.FileUpdateResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if response.Error != "" { - return nil, fmt.Errorf("file update error: %s", response.Error) - } - - if !response.Success { - return nil, fmt.Errorf("file update failed") - } - - return &response, nil -} - -// CreateFile creates a new file via HTTP POST -func (c *Client) CreateFile(ctx context.Context, path string) (*streams.FileCreateResponse, error) { - reqBody := streams.FileCreateRequest{Path: path} - - bodyBytes, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - reqURL := fmt.Sprintf("%s/api/v1/workspace/file/create", c.baseURL) - req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(bodyBytes)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to create file: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logger.Debug("failed to close file create response body", zap.Error(err)) - } - }() - - var response streams.FileCreateResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if response.Error != "" { - return nil, fmt.Errorf("file create error: %s", response.Error) - } - - if !response.Success { - return nil, fmt.Errorf("file create failed") - } - - return &response, nil -} - -// DeleteFile deletes a file via HTTP DELETE -func (c *Client) DeleteFile(ctx context.Context, path string) (*streams.FileDeleteResponse, error) { - reqURL := fmt.Sprintf("%s/api/v1/workspace/file?path=%s", c.baseURL, path) - - req, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to delete file: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logger.Debug("failed to close file delete response body", zap.Error(err)) - } - }() - - var response streams.FileDeleteResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if response.Error != "" { - return nil, fmt.Errorf("file delete error: %s", response.Error) - } - - if !response.Success { - return nil, fmt.Errorf("file delete failed") - } - - return &response, nil -} - -// sendStreamRequest sends a request over the agent WebSocket stream and waits for a response. -// It creates a ws.Message with a UUID, registers a pending response channel, -// writes the message to the stream, and blocks until a response arrives or context is cancelled. -func (c *Client) sendStreamRequest(ctx context.Context, action string, payload interface{}) (*ws.Message, error) { - c.mu.RLock() - conn := c.agentStreamConn - c.mu.RUnlock() - - if conn == nil { - return nil, fmt.Errorf("agent stream not connected") - } - - reqID := uuid.New().String() - msg, err := ws.NewRequest(reqID, action, payload) - if err != nil { - return nil, fmt.Errorf("failed to create request message: %w", err) - } - - // Register pending request - respCh := make(chan *ws.Message, 1) - c.pendingMu.Lock() - c.pendingRequests[reqID] = respCh - c.pendingMu.Unlock() - - // Clean up on exit - defer func() { - c.pendingMu.Lock() - delete(c.pendingRequests, reqID) - c.pendingMu.Unlock() - }() - - // Serialize write to stream - data, err := json.Marshal(msg) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - c.streamWriteMu.Lock() - writeErr := conn.WriteMessage(websocket.TextMessage, data) - c.streamWriteMu.Unlock() - if writeErr != nil { - return nil, fmt.Errorf("failed to write request to stream: %w", writeErr) - } - - // Wait for response or context cancellation - select { - case resp := <-respCh: - if resp == nil { - return nil, fmt.Errorf("agent stream disconnected while waiting for response") - } - return resp, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -// resolvePendingRequest matches a response message to a pending request by ID. -// Returns true if the message was matched to a pending request. -func (c *Client) resolvePendingRequest(msg *ws.Message) bool { - if msg.ID == "" { - return false - } - - c.pendingMu.Lock() - ch, ok := c.pendingRequests[msg.ID] - c.pendingMu.Unlock() - - if !ok { - return false - } - - // Send response to waiting caller (non-blocking since channel is buffered) - select { - case ch <- msg: - default: - } - return true -} - -// cleanupPendingRequests unblocks all pending requests with nil (signaling disconnect). -func (c *Client) cleanupPendingRequests() { - c.pendingMu.Lock() - defer c.pendingMu.Unlock() - - for id, ch := range c.pendingRequests { - close(ch) - delete(c.pendingRequests, id) - } -} - // Close closes all connections func (c *Client) Close() { c.CloseUpdatesStream() c.CloseWorkspaceStream() } -// Note: ShellStatusResponse, ShellMessage, ShellBufferResponse are re-exported -// from streams package at the top of this file in the type block. - -// ShellStatus gets the status of the embedded shell session -func (c *Client) ShellStatus(ctx context.Context) (*ShellStatusResponse, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/shell/status", nil) - if err != nil { - return nil, err - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("shell status failed: %s", resp.Status) - } - - var result ShellStatusResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - return &result, nil -} - -// ShellBuffer returns the buffered shell output -func (c *Client) ShellBuffer(ctx context.Context) (string, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/shell/buffer", nil) - if err != nil { - return "", err - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("shell buffer failed: %s", resp.Status) - } - - var result ShellBufferResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err - } - - return result.Data, nil -} - -// StartShell starts the shell session without starting the agent process. -// This is used in passthrough mode where the agent runs directly via InteractiveRunner. -func (c *Client) StartShell(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/shell/start", nil) - if err != nil { - return err - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("start shell failed: %s", resp.Status) - } - - return 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) - OnGitCommit func(notification *GitCommitNotification) - OnGitReset func(notification *GitResetNotification) - OnFileChange func(notification *FileChangeNotification) - OnProcessOutput func(output *types.ProcessOutput) - OnProcessStatus func(status *types.ProcessStatusUpdate) - OnConnected func() - OnError func(err string) -} - -// WorkspaceStream represents an active workspace stream connection -type WorkspaceStream struct { - conn *websocket.Conn - inputCh chan types.WorkspaceStreamMessage - closeCh chan struct{} - closeOnce sync.Once - logger *logger.Logger -} - -// StreamWorkspace opens a unified WebSocket connection for all workspace events -func (c *Client) StreamWorkspace(ctx context.Context, callbacks WorkspaceStreamCallbacks) (*WorkspaceStream, error) { - c.mu.Lock() - if c.workspaceStreamConn != nil { - c.mu.Unlock() - return nil, fmt.Errorf("workspace stream already connected") - } - c.mu.Unlock() - - wsURL := "ws" + c.baseURL[4:] + "/api/v1/workspace/stream" - conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to connect to workspace stream: %w", err) - } - - c.mu.Lock() - c.workspaceStreamConn = conn - c.mu.Unlock() - - c.logger.Info("connected to workspace stream", zap.String("url", wsURL)) - - ws := &WorkspaceStream{ - conn: conn, - inputCh: make(chan types.WorkspaceStreamMessage, 64), - closeCh: make(chan struct{}), - logger: c.logger, - } - - // Read goroutine - dispatches to callbacks - go func() { - defer func() { - c.mu.Lock() - c.workspaceStreamConn = nil - c.mu.Unlock() - ws.Close() - }() - - for { - var msg types.WorkspaceStreamMessage - if err := conn.ReadJSON(&msg); err != nil { - if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - c.logger.Debug("workspace stream read error", zap.Error(err)) - } - return - } - - switch msg.Type { - case types.WorkspaceMessageTypeShellOutput: - if callbacks.OnShellOutput != nil { - callbacks.OnShellOutput(msg.Data) - } - case types.WorkspaceMessageTypeShellExit: - if callbacks.OnShellExit != nil { - callbacks.OnShellExit(msg.Code) - } - case types.WorkspaceMessageTypeGitStatus: - if callbacks.OnGitStatus != nil && msg.GitStatus != nil { - callbacks.OnGitStatus(msg.GitStatus) - } - case types.WorkspaceMessageTypeGitCommit: - if callbacks.OnGitCommit != nil && msg.GitCommit != nil { - callbacks.OnGitCommit(msg.GitCommit) - } - case types.WorkspaceMessageTypeGitReset: - if callbacks.OnGitReset != nil && msg.GitReset != nil { - callbacks.OnGitReset(msg.GitReset) - } - case types.WorkspaceMessageTypeFileChange: - if callbacks.OnFileChange != nil && msg.FileChange != nil { - callbacks.OnFileChange(msg.FileChange) - } - 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() - } - case types.WorkspaceMessageTypeError: - if callbacks.OnError != nil { - callbacks.OnError(msg.Error) - } - } - } - }() - - // Write goroutine - sends from inputCh - go func() { - for { - select { - case <-ws.closeCh: - return - case msg, ok := <-ws.inputCh: - if !ok { - return - } - if err := conn.WriteJSON(msg); err != nil { - ws.logger.Debug("workspace stream write error", zap.Error(err)) - return - } - } - } - }() - - return ws, nil -} - -// WriteShellInput sends input to the shell through the workspace stream -func (ws *WorkspaceStream) WriteShellInput(data string) error { - msg := types.NewWorkspaceShellInput(data) - select { - case ws.inputCh <- msg: - return nil - case <-ws.closeCh: - return fmt.Errorf("workspace stream closed") - } -} - -// ResizeShell sends a shell resize command through the workspace stream -func (ws *WorkspaceStream) ResizeShell(cols, rows int) error { - msg := types.NewWorkspaceShellResize(cols, rows) - select { - case ws.inputCh <- msg: - return nil - case <-ws.closeCh: - return fmt.Errorf("workspace stream closed") - } -} - -// Ping sends a ping message through the workspace stream -func (ws *WorkspaceStream) Ping() error { - msg := types.NewWorkspacePing() - select { - case ws.inputCh <- msg: - return nil - case <-ws.closeCh: - return fmt.Errorf("workspace stream closed") - } -} - -// Close closes the workspace stream -func (ws *WorkspaceStream) Close() { - ws.closeOnce.Do(func() { - close(ws.closeCh) - if ws.conn != nil { - if err := ws.conn.Close(); err != nil { - ws.logger.Debug("failed to close workspace stream connection", zap.Error(err)) - } - } - }) -} - -// Done returns a channel that is closed when the stream is closed -func (ws *WorkspaceStream) Done() <-chan struct{} { - return ws.closeCh -} - -// CloseWorkspaceStream closes the workspace stream connection -func (c *Client) CloseWorkspaceStream() { - c.mu.Lock() - defer c.mu.Unlock() - - if c.workspaceStreamConn != nil { - if err := c.workspaceStreamConn.Close(); err != nil { - c.logger.Debug("failed to close workspace stream", zap.Error(err)) - } - c.workspaceStreamConn = nil - } -} - // readResponseBody reads and returns the response body func readResponseBody(resp *http.Response) ([]byte, error) { var buf bytes.Buffer diff --git a/apps/backend/internal/agentctl/client/client_files.go b/apps/backend/internal/agentctl/client/client_files.go new file mode 100644 index 0000000000..a429896801 --- /dev/null +++ b/apps/backend/internal/agentctl/client/client_files.go @@ -0,0 +1,235 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "go.uber.org/zap" +) + +// RequestFileTree requests a file tree via HTTP GET +func (c *Client) RequestFileTree(ctx context.Context, path string, depth int) (*FileTreeResponse, error) { + reqURL := fmt.Sprintf("%s/api/v1/workspace/tree?path=%s&depth=%d", c.baseURL, path, depth) + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request file tree: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file tree response body", zap.Error(err)) + } + }() + + var response FileTreeResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != "" { + return nil, fmt.Errorf("file tree error: %s", response.Error) + } + + return &response, nil +} + +// SearchFiles searches for files matching the query via HTTP GET +func (c *Client) SearchFiles(ctx context.Context, query string, limit int) (*FileSearchResponse, error) { + reqURL := fmt.Sprintf("%s/api/v1/workspace/search?q=%s&limit=%d", c.baseURL, url.QueryEscape(query), limit) + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to search files: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file search response body", zap.Error(err)) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("file search failed with status %d", resp.StatusCode) + } + + var result FileSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// RequestFileContent requests file content via HTTP GET +func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileContentResponse, error) { + reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content?path=%s", c.baseURL, path) + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request file content: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file content response body", zap.Error(err)) + } + }() + + var response FileContentResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != "" { + return nil, fmt.Errorf("file content error: %s", response.Error) + } + + return &response, nil +} + +// ApplyFileDiff applies a unified diff to a file via HTTP POST +func (c *Client) ApplyFileDiff(ctx context.Context, path string, diff string, originalHash string) (*streams.FileUpdateResponse, error) { + reqBody := streams.FileUpdateRequest{ + Path: path, + Diff: diff, + OriginalHash: originalHash, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content", c.baseURL) + req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to apply file diff: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file update response body", zap.Error(err)) + } + }() + + var response streams.FileUpdateResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != "" { + return nil, fmt.Errorf("file update error: %s", response.Error) + } + + if !response.Success { + return nil, fmt.Errorf("file update failed") + } + + return &response, nil +} + +// CreateFile creates a new file via HTTP POST +func (c *Client) CreateFile(ctx context.Context, path string) (*streams.FileCreateResponse, error) { + reqBody := streams.FileCreateRequest{Path: path} + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/api/v1/workspace/file/create", c.baseURL) + req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to create file: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file create response body", zap.Error(err)) + } + }() + + var response streams.FileCreateResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != "" { + return nil, fmt.Errorf("file create error: %s", response.Error) + } + + if !response.Success { + return nil, fmt.Errorf("file create failed") + } + + return &response, nil +} + +// DeleteFile deletes a file via HTTP DELETE +func (c *Client) DeleteFile(ctx context.Context, path string) (*streams.FileDeleteResponse, error) { + reqURL := fmt.Sprintf("%s/api/v1/workspace/file?path=%s", c.baseURL, path) + + req, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to delete file: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file delete response body", zap.Error(err)) + } + }() + + var response streams.FileDeleteResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != "" { + return nil, fmt.Errorf("file delete error: %s", response.Error) + } + + if !response.Success { + return nil, fmt.Errorf("file delete failed") + } + + return &response, nil +} + +// FileSearchResponse represents a response with matching files +type FileSearchResponse struct { + Files []string `json:"files"` + Error string `json:"error,omitempty"` +} diff --git a/apps/backend/internal/agentctl/client/client_process.go b/apps/backend/internal/agentctl/client/client_process.go new file mode 100644 index 0000000000..e5e4024061 --- /dev/null +++ b/apps/backend/internal/agentctl/client/client_process.go @@ -0,0 +1,184 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// StartProcessRequest contains the parameters for starting a process. +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"` +} + +// ProcessOutputChunk is a single chunk of process output. +type ProcessOutputChunk struct { + Stream string `json:"stream"` + Data string `json:"data"` + Timestamp time.Time `json:"timestamp"` +} + +// ProcessInfo contains information about a running or completed process. +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"` +} + +// StartProcess starts a new process via HTTP POST +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 +} + +// StopProcess stops a running process via HTTP POST +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 +} + +// ListProcesses lists processes, optionally filtered by session ID +func (c *Client) ListProcesses(ctx context.Context, sessionID string) ([]ProcessInfo, error) { + reqURL := c.baseURL + "/api/v1/processes" + if sessionID != "" { + reqURL = reqURL + "?session_id=" + sessionID + } + httpReq, err := http.NewRequestWithContext(ctx, "GET", reqURL, 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 +} + +// GetProcess retrieves a specific process by ID +func (c *Client) GetProcess(ctx context.Context, id string, includeOutput bool) (*ProcessInfo, error) { + reqURL := c.baseURL + "/api/v1/processes/" + id + if includeOutput { + reqURL += "?include_output=true" + } + httpReq, err := http.NewRequestWithContext(ctx, "GET", reqURL, 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 +} diff --git a/apps/backend/internal/agentctl/client/client_shell.go b/apps/backend/internal/agentctl/client/client_shell.go new file mode 100644 index 0000000000..b847d9f0ad --- /dev/null +++ b/apps/backend/internal/agentctl/client/client_shell.go @@ -0,0 +1,82 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Note: ShellStatusResponse, ShellMessage, ShellBufferResponse are re-exported +// from types package in client.go. + +// ShellStatus gets the status of the embedded shell session +func (c *Client) ShellStatus(ctx context.Context) (*ShellStatusResponse, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/shell/status", nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("shell status failed: %s", resp.Status) + } + + var result ShellStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// ShellBuffer returns the buffered shell output +func (c *Client) ShellBuffer(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/shell/buffer", nil) + if err != nil { + return "", err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("shell buffer failed: %s", resp.Status) + } + + var result ShellBufferResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + return result.Data, nil +} + +// StartShell starts the shell session without starting the agent process. +// This is used in passthrough mode where the agent runs directly via InteractiveRunner. +func (c *Client) StartShell(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/shell/start", nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("start shell failed: %s", resp.Status) + } + + return nil +} diff --git a/apps/backend/internal/agentctl/client/client_stream.go b/apps/backend/internal/agentctl/client/client_stream.go new file mode 100644 index 0000000000..531a3347a2 --- /dev/null +++ b/apps/backend/internal/agentctl/client/client_stream.go @@ -0,0 +1,101 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// sendStreamRequest sends a request over the agent WebSocket stream and waits for a response. +// It creates a ws.Message with a UUID, registers a pending response channel, +// writes the message to the stream, and blocks until a response arrives or context is cancelled. +func (c *Client) sendStreamRequest(ctx context.Context, action string, payload interface{}) (*ws.Message, error) { + c.mu.RLock() + conn := c.agentStreamConn + c.mu.RUnlock() + + if conn == nil { + return nil, fmt.Errorf("agent stream not connected") + } + + reqID := uuid.New().String() + msg, err := ws.NewRequest(reqID, action, payload) + if err != nil { + return nil, fmt.Errorf("failed to create request message: %w", err) + } + + // Register pending request + respCh := make(chan *ws.Message, 1) + c.pendingMu.Lock() + c.pendingRequests[reqID] = respCh + c.pendingMu.Unlock() + + // Clean up on exit + defer func() { + c.pendingMu.Lock() + delete(c.pendingRequests, reqID) + c.pendingMu.Unlock() + }() + + // Serialize write to stream + data, err := json.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + c.streamWriteMu.Lock() + writeErr := conn.WriteMessage(websocket.TextMessage, data) + c.streamWriteMu.Unlock() + if writeErr != nil { + return nil, fmt.Errorf("failed to write request to stream: %w", writeErr) + } + + // Wait for response or context cancellation + select { + case resp := <-respCh: + if resp == nil { + return nil, fmt.Errorf("agent stream disconnected while waiting for response") + } + return resp, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// resolvePendingRequest matches a response message to a pending request by ID. +// Returns true if the message was matched to a pending request. +func (c *Client) resolvePendingRequest(msg *ws.Message) bool { + if msg.ID == "" { + return false + } + + c.pendingMu.Lock() + ch, ok := c.pendingRequests[msg.ID] + c.pendingMu.Unlock() + + if !ok { + return false + } + + // Send response to waiting caller (non-blocking since channel is buffered) + select { + case ch <- msg: + default: + } + return true +} + +// cleanupPendingRequests unblocks all pending requests with nil (signaling disconnect). +func (c *Client) cleanupPendingRequests() { + c.pendingMu.Lock() + defer c.pendingMu.Unlock() + + for id, ch := range c.pendingRequests { + close(ch) + delete(c.pendingRequests, id) + } +} diff --git a/apps/backend/internal/agentctl/client/workspace_dispatch.go b/apps/backend/internal/agentctl/client/workspace_dispatch.go new file mode 100644 index 0000000000..066a97d863 --- /dev/null +++ b/apps/backend/internal/agentctl/client/workspace_dispatch.go @@ -0,0 +1,77 @@ +package client + +import ( + "github.com/kandev/kandev/internal/agentctl/types" +) + +func dispatchWorkspaceShellMessages(msg types.WorkspaceStreamMessage, callbacks WorkspaceStreamCallbacks) bool { + switch msg.Type { + case types.WorkspaceMessageTypeShellOutput: + if callbacks.OnShellOutput != nil { + callbacks.OnShellOutput(msg.Data) + } + case types.WorkspaceMessageTypeShellExit: + if callbacks.OnShellExit != nil { + callbacks.OnShellExit(msg.Code) + } + case types.WorkspaceMessageTypeConnected: + if callbacks.OnConnected != nil { + callbacks.OnConnected() + } + case types.WorkspaceMessageTypeError: + if callbacks.OnError != nil { + callbacks.OnError(msg.Error) + } + default: + return false + } + return true +} + +func dispatchWorkspaceGitMessages(msg types.WorkspaceStreamMessage, callbacks WorkspaceStreamCallbacks) bool { + switch msg.Type { + case types.WorkspaceMessageTypeGitStatus: + if callbacks.OnGitStatus != nil && msg.GitStatus != nil { + callbacks.OnGitStatus(msg.GitStatus) + } + case types.WorkspaceMessageTypeGitCommit: + if callbacks.OnGitCommit != nil && msg.GitCommit != nil { + callbacks.OnGitCommit(msg.GitCommit) + } + case types.WorkspaceMessageTypeGitReset: + if callbacks.OnGitReset != nil && msg.GitReset != nil { + callbacks.OnGitReset(msg.GitReset) + } + case types.WorkspaceMessageTypeFileChange: + if callbacks.OnFileChange != nil && msg.FileChange != nil { + callbacks.OnFileChange(msg.FileChange) + } + default: + return false + } + return true +} + +func dispatchWorkspaceProcessMessages(msg types.WorkspaceStreamMessage, callbacks WorkspaceStreamCallbacks) { + switch msg.Type { + 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) + } + } +} + +// dispatchWorkspaceMessage routes a workspace stream message to the appropriate callback. +func dispatchWorkspaceMessage(msg types.WorkspaceStreamMessage, callbacks WorkspaceStreamCallbacks) { + if dispatchWorkspaceShellMessages(msg, callbacks) { + return + } + if dispatchWorkspaceGitMessages(msg, callbacks) { + return + } + dispatchWorkspaceProcessMessages(msg, callbacks) +} diff --git a/apps/backend/internal/agentctl/client/workspace_stream.go b/apps/backend/internal/agentctl/client/workspace_stream.go new file mode 100644 index 0000000000..960acbd647 --- /dev/null +++ b/apps/backend/internal/agentctl/client/workspace_stream.go @@ -0,0 +1,171 @@ +package client + +import ( + "context" + "fmt" + "sync" + + "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// WorkspaceStreamCallbacks defines callbacks for workspace stream events +type WorkspaceStreamCallbacks struct { + OnShellOutput func(data string) + OnShellExit func(code int) + OnGitStatus func(update *GitStatusUpdate) + OnGitCommit func(notification *GitCommitNotification) + OnGitReset func(notification *GitResetNotification) + OnFileChange func(notification *FileChangeNotification) + OnProcessOutput func(output *types.ProcessOutput) + OnProcessStatus func(status *types.ProcessStatusUpdate) + OnConnected func() + OnError func(err string) +} + +// WorkspaceStream represents an active workspace stream connection +type WorkspaceStream struct { + conn *websocket.Conn + inputCh chan types.WorkspaceStreamMessage + closeCh chan struct{} + closeOnce sync.Once + logger *logger.Logger +} + +// StreamWorkspace opens a unified WebSocket connection for all workspace events +func (c *Client) StreamWorkspace(ctx context.Context, callbacks WorkspaceStreamCallbacks) (*WorkspaceStream, error) { + c.mu.Lock() + if c.workspaceStreamConn != nil { + c.mu.Unlock() + return nil, fmt.Errorf("workspace stream already connected") + } + c.mu.Unlock() + + wsURL := "ws" + c.baseURL[4:] + "/api/v1/workspace/stream" + conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to connect to workspace stream: %w", err) + } + + c.mu.Lock() + c.workspaceStreamConn = conn + c.mu.Unlock() + + c.logger.Info("connected to workspace stream", zap.String("url", wsURL)) + + stream := &WorkspaceStream{ + conn: conn, + inputCh: make(chan types.WorkspaceStreamMessage, 64), + closeCh: make(chan struct{}), + logger: c.logger, + } + + go c.readWorkspaceStream(conn, stream, callbacks) + go stream.writeLoop(conn) + + return stream, nil +} + +// readWorkspaceStream is the read loop for the workspace WebSocket stream. +func (c *Client) readWorkspaceStream(conn *websocket.Conn, stream *WorkspaceStream, callbacks WorkspaceStreamCallbacks) { + defer func() { + c.mu.Lock() + c.workspaceStreamConn = nil + c.mu.Unlock() + stream.Close() + }() + + for { + var msg types.WorkspaceStreamMessage + if err := conn.ReadJSON(&msg); err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + c.logger.Debug("workspace stream read error", zap.Error(err)) + } + return + } + dispatchWorkspaceMessage(msg, callbacks) + } +} + +// writeLoop reads from the inputCh and writes messages to the WebSocket connection. +func (ws *WorkspaceStream) writeLoop(conn *websocket.Conn) { + for { + select { + case <-ws.closeCh: + return + case msg, ok := <-ws.inputCh: + if !ok { + return + } + if err := conn.WriteJSON(msg); err != nil { + ws.logger.Debug("workspace stream write error", zap.Error(err)) + return + } + } + } +} + +// WriteShellInput sends input to the shell through the workspace stream +func (ws *WorkspaceStream) WriteShellInput(data string) error { + msg := types.NewWorkspaceShellInput(data) + select { + case ws.inputCh <- msg: + return nil + case <-ws.closeCh: + return fmt.Errorf("workspace stream closed") + } +} + +// ResizeShell sends a shell resize command through the workspace stream +func (ws *WorkspaceStream) ResizeShell(cols, rows int) error { + msg := types.NewWorkspaceShellResize(cols, rows) + select { + case ws.inputCh <- msg: + return nil + case <-ws.closeCh: + return fmt.Errorf("workspace stream closed") + } +} + +// Ping sends a ping message through the workspace stream +func (ws *WorkspaceStream) Ping() error { + msg := types.NewWorkspacePing() + select { + case ws.inputCh <- msg: + return nil + case <-ws.closeCh: + return fmt.Errorf("workspace stream closed") + } +} + +// Close closes the workspace stream +func (ws *WorkspaceStream) Close() { + ws.closeOnce.Do(func() { + close(ws.closeCh) + if ws.conn != nil { + if err := ws.conn.Close(); err != nil { + ws.logger.Debug("failed to close workspace stream connection", zap.Error(err)) + } + } + }) +} + +// Done returns a channel that is closed when the stream is closed +func (ws *WorkspaceStream) Done() <-chan struct{} { + return ws.closeCh +} + +// CloseWorkspaceStream closes the workspace stream connection +func (c *Client) CloseWorkspaceStream() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.workspaceStreamConn != nil { + if err := c.workspaceStreamConn.Close(); err != nil { + c.logger.Debug("failed to close workspace stream", zap.Error(err)) + } + c.workspaceStreamConn = nil + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go index 5f09aa73af..c1b618c40b 100644 --- a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go @@ -392,6 +392,90 @@ func (a *AmpAdapter) handleUserMessage(msg *amp.Message, sessionID, operationID } } +// updateMainModel updates agent version and tracks the main model name. +func (a *AmpAdapter) updateMainModel(model string) { + if model == "" || a.agentInfo == nil { + return + } + a.agentInfo.Version = model + a.mu.Lock() + if a.mainModelName == "" { + a.mainModelName = model + a.logger.Debug("tracking main model", zap.String("model", model)) + } + a.mu.Unlock() +} + +// emitContextWindow emits a context window event. +func (a *AmpAdapter) emitContextWindow(sessionID, operationID string, contextUsed, contextSize int64) { + remaining := contextSize - contextUsed + if remaining < 0 { + remaining = 0 + } + a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + OperationID: operationID, + ContextWindowSize: contextSize, + ContextWindowUsed: contextUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, + }) +} + +// trackUsageAndEmitContextWindow updates token tracking and emits a context window event. +func (a *AmpAdapter) trackUsageAndEmitContextWindow(usage *amp.TokenUsage, sessionID, operationID string) { + if usage == nil { + return + } + contextUsed := usage.InputTokens + usage.OutputTokens + + usage.CacheCreationInputTokens + usage.CacheReadInputTokens + + a.mu.Lock() + a.contextTokensUsed = contextUsed + contextSize := a.mainModelContextWindow + a.mu.Unlock() + + a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) +} + +// processAssistantContentBlock processes a single content block from an assistant message. +func (a *AmpAdapter) processAssistantContentBlock(block *amp.ContentBlock, sessionID, operationID string) { + switch block.Type { + case amp.ContentTypeText: + if block.Text != "" { + // Accumulate text locally to include in complete event + // This bypasses the race condition with the manager's buffer + a.mu.Lock() + a.textAccumulator.WriteString(block.Text) + a.mu.Unlock() + + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: block.Text, + }) + } + + case amp.ContentTypeThinking: + if block.Thinking != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeReasoning, + SessionID: sessionID, + OperationID: operationID, + ReasoningText: block.Thinking, + }) + } + + case amp.ContentTypeToolUse: + a.handleToolUse(block, sessionID, operationID) + + case amp.ContentTypeToolResult: + a.handleToolResult(block, sessionID, operationID) + } +} + // handleAssistantMessage processes assistant messages. func (a *AmpAdapter) handleAssistantMessage(msg *amp.Message, sessionID, operationID string) { if msg.Message == nil { @@ -408,80 +492,15 @@ func (a *AmpAdapter) handleAssistantMessage(msg *amp.Message, sessionID, operati zap.Strings("block_types", blockTypes)) // Update agent version and track main model - if msg.Message.Model != "" && a.agentInfo != nil { - a.agentInfo.Version = msg.Message.Model - - a.mu.Lock() - if a.mainModelName == "" { - a.mainModelName = msg.Message.Model - a.logger.Debug("tracking main model", zap.String("model", msg.Message.Model)) - } - a.mu.Unlock() - } + a.updateMainModel(msg.Message.Model) // Process content blocks - for _, block := range msg.Message.Content { - switch block.Type { - case amp.ContentTypeText: - if block.Text != "" { - // Accumulate text locally to include in complete event - // This bypasses the race condition with the manager's buffer - a.mu.Lock() - a.textAccumulator.WriteString(block.Text) - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: block.Text, - }) - } - - case amp.ContentTypeThinking: - if block.Thinking != "" { - a.sendUpdate(AgentEvent{ - Type: EventTypeReasoning, - SessionID: sessionID, - OperationID: operationID, - ReasoningText: block.Thinking, - }) - } - - case amp.ContentTypeToolUse: - a.handleToolUse(&block, sessionID, operationID) - - case amp.ContentTypeToolResult: - a.handleToolResult(&block, sessionID, operationID) - } + for i := range msg.Message.Content { + a.processAssistantContentBlock(&msg.Message.Content[i], sessionID, operationID) } // Calculate and emit token usage - if msg.Message.Usage != nil { - usage := msg.Message.Usage - contextUsed := usage.InputTokens + usage.OutputTokens + - usage.CacheCreationInputTokens + usage.CacheReadInputTokens - - a.mu.Lock() - a.contextTokensUsed = contextUsed - contextSize := a.mainModelContextWindow - a.mu.Unlock() - - remaining := contextSize - contextUsed - if remaining < 0 { - remaining = 0 - } - - a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, - SessionID: sessionID, - OperationID: operationID, - ContextWindowSize: contextSize, - ContextWindowUsed: contextUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, - }) - } + a.trackUsageAndEmitContextWindow(msg.Message.Usage, sessionID, operationID) // Check for turn completion - Amp signals this via stop_reason in assistant messages // (unlike Claude Code which sends a separate "result" message) @@ -609,19 +628,7 @@ func (a *AmpAdapter) handleTurnComplete(sessionID, operationID string) { // Emit final context window event if contextUsed > 0 { - remaining := contextSize - contextUsed - if remaining < 0 { - remaining = 0 - } - a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, - SessionID: sessionID, - OperationID: operationID, - ContextWindowSize: contextSize, - ContextWindowUsed: contextUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, - }) + a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) } // Send completion event WITHOUT text - text was already sent via message_chunk events @@ -650,6 +657,71 @@ func (a *AmpAdapter) handleTurnComplete(sessionID, operationID string) { } } +// extractResultErrorMsg extracts the error message from a result message. +func (a *AmpAdapter) extractResultErrorMsg(msg *amp.Message) string { + if !msg.IsError { + return "" + } + if msg.Error != "" { + return msg.Error + } + if errStr := msg.GetResultString(); errStr != "" { + return errStr + } + if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { + return resultData.Text + } + return "prompt failed" +} + +// signalResultCompletion sends the result to the result channel. +func (a *AmpAdapter) signalResultCompletion(success bool, errMsg string) { + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + if resultCh == nil { + return + } + select { + case resultCh <- resultComplete{success: success, err: errMsg}: + a.logger.Debug("signaled prompt completion") + default: + a.logger.Warn("result channel full, dropping signal") + } +} + +// updateContextWindowFromModelUsage updates the context window size from model_usage. +func (a *AmpAdapter) updateContextWindowFromModelUsage(msg *amp.Message) (contextUsed, contextSize int64) { + a.mu.Lock() + modelName := a.mainModelName + if msg.ModelUsage != nil && modelName != "" { + if modelStats, ok := msg.ModelUsage[modelName]; ok && modelStats.ContextWindow != nil { + a.mainModelContextWindow = *modelStats.ContextWindow + a.logger.Debug("updated context window from model_usage", + zap.String("model", modelName), + zap.Int64("context_window", a.mainModelContextWindow)) + } + } + contextSize = a.mainModelContextWindow + contextUsed = a.contextTokensUsed + a.mu.Unlock() + return contextUsed, contextSize +} + +// drainPendingToolCalls atomically takes all pending tool calls and clears them. +func (a *AmpAdapter) drainPendingToolCalls() map[string]*streams.NormalizedPayload { + a.mu.Lock() + pending := make(map[string]*streams.NormalizedPayload, len(a.pendingToolCalls)) + for toolID := range a.pendingToolCalls { + pending[toolID] = a.pendingToolPayloads[toolID] + } + a.pendingToolCalls = make(map[string]bool) + a.pendingToolPayloads = make(map[string]*streams.NormalizedPayload) + a.mu.Unlock() + return pending +} + // handleResultMessage processes result (completion) messages. func (a *AmpAdapter) handleResultMessage(msg *amp.Message, sessionID, operationID string) { a.logger.Info("received result message", @@ -674,16 +746,10 @@ func (a *AmpAdapter) handleResultMessage(msg *amp.Message, sessionID, operationI if !alreadyCompleted { a.completeSent = true } - - // Auto-complete any pending tool calls - pendingTools := make(map[string]*streams.NormalizedPayload, len(a.pendingToolCalls)) - for toolID := range a.pendingToolCalls { - pendingTools[toolID] = a.pendingToolPayloads[toolID] - } - a.pendingToolCalls = make(map[string]bool) - a.pendingToolPayloads = make(map[string]*streams.NormalizedPayload) a.mu.Unlock() + // Auto-complete any pending tool calls + pendingTools := a.drainPendingToolCalls() for toolID, cachedPayload := range pendingTools { a.logger.Info("auto-completing pending tool call on result", zap.String("tool_call_id", toolID)) @@ -698,35 +764,11 @@ func (a *AmpAdapter) handleResultMessage(msg *amp.Message, sessionID, operationI } // Extract context window from model_usage if available - a.mu.Lock() - modelName := a.mainModelName - if msg.ModelUsage != nil && modelName != "" { - if modelStats, ok := msg.ModelUsage[modelName]; ok && modelStats.ContextWindow != nil { - a.mainModelContextWindow = *modelStats.ContextWindow - a.logger.Debug("updated context window from model_usage", - zap.String("model", modelName), - zap.Int64("context_window", a.mainModelContextWindow)) - } - } - contextSize := a.mainModelContextWindow - contextUsed := a.contextTokensUsed - a.mu.Unlock() + contextUsed, contextSize := a.updateContextWindowFromModelUsage(msg) // Emit final context window event if contextUsed > 0 { - remaining := contextSize - contextUsed - if remaining < 0 { - remaining = 0 - } - a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, - SessionID: sessionID, - OperationID: operationID, - ContextWindowSize: contextSize, - ContextWindowUsed: contextUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, - }) + a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) } // Only send completion event if not already sent by handleTurnComplete @@ -753,32 +795,10 @@ func (a *AmpAdapter) handleResultMessage(msg *amp.Message, sessionID, operationI } // Extract error message if failed - var errorMsg string - if msg.IsError { - errorMsg = "prompt failed" - // Try the error field first (Amp sends errors in this field) - if msg.Error != "" { - errorMsg = msg.Error - } else if errStr := msg.GetResultString(); errStr != "" { - errorMsg = errStr - } else if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { - errorMsg = resultData.Text - } - } + errorMsg := a.extractResultErrorMsg(msg) // Signal completion - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - if resultCh != nil { - select { - case resultCh <- resultComplete{success: !msg.IsError, err: errorMsg}: - a.logger.Debug("signaled prompt completion") - default: - a.logger.Warn("result channel full, dropping signal") - } - } + a.signalResultCompletion(!msg.IsError, errorMsg) // Send error event if failed if msg.IsError { diff --git a/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go b/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go index 78df570b67..5037bb6a38 100644 --- a/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go +++ b/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go @@ -64,80 +64,119 @@ func (n *AmpNormalizer) NormalizeToolCall(toolName string, args map[string]any) // Amp's tool results are JSON-wrapped strings like "{\"output\":\"...\",\"exitCode\":0}". func (n *AmpNormalizer) NormalizeToolResult(cachedPayload *streams.NormalizedPayload, content any, isError bool) *streams.NormalizedPayload { if cachedPayload == nil { - // No cached payload, create a generic one with the result - output, _ := n.parseToolResultContent(content) - payload := streams.NewGeneric("unknown", nil) - if payload.Generic() != nil { - payload.Generic().Output = output - } - return payload + return n.normalizeResultNoCached(content) } - // Update the cached payload with the result switch cachedPayload.Kind() { case streams.ToolKindShellExec: - if cachedPayload.ShellExec() != nil { - output, exitCode := n.parseToolResultContent(content) - cachedPayload.ShellExec().Output = &streams.ShellExecOutput{ - Stdout: output, - ExitCode: exitCode, - } - if isError { - cachedPayload.ShellExec().Output.Stderr = output - cachedPayload.ShellExec().Output.Stdout = "" - } - } + n.normalizeShellExecResult(cachedPayload, content, isError) case streams.ToolKindHttpRequest: - if cachedPayload.HttpRequest() != nil { - output, _ := n.parseToolResultContent(content) - cachedPayload.HttpRequest().Response = output - } + n.normalizeHttpRequestResult(cachedPayload, content) case streams.ToolKindGeneric: - if cachedPayload.Generic() != nil { - output, _ := n.parseToolResultContent(content) - cachedPayload.Generic().Output = output - } + n.normalizeGenericResult(cachedPayload, content) case streams.ToolKindReadFile: - // Parse Amp's read result format: {absolutePath, content, isDirectory, directoryEntries} - if cachedPayload.ReadFile() != nil { - result := n.parseAmpReadResult(content) - if result != nil { - // If it's a directory, convert to code_search - if result.IsDirectory { - codeSearch := streams.NewCodeSearch( - "", // query - "", // pattern - result.AbsolutePath, // path - "*", // glob - all entries - ) - if codeSearch.CodeSearch() != nil { - codeSearch.CodeSearch().Output = &streams.CodeSearchOutput{ - Files: result.DirectoryEntries, - FileCount: len(result.DirectoryEntries), - } - } - return codeSearch - } - // Regular file read - output := &streams.ReadFileOutput{ - Content: result.Content, - } - if result.Content != "" { - output.LineCount = strings.Count(result.Content, "\n") + 1 - } - cachedPayload.ReadFile().Output = output - } - } + return n.normalizeReadFileResult(cachedPayload, content) case streams.ToolKindCodeSearch: - if cachedPayload.CodeSearch() != nil { - output, _ := n.parseToolResultContent(content) - shared.NormalizeCodeSearchResult(cachedPayload.CodeSearch(), output) - } + n.normalizeCodeSearchResult(cachedPayload, content) } return cachedPayload } +// normalizeResultNoCached creates a generic payload when no cached payload exists. +func (n *AmpNormalizer) normalizeResultNoCached(content any) *streams.NormalizedPayload { + output, _ := n.parseToolResultContent(content) + payload := streams.NewGeneric("unknown", nil) + if payload.Generic() != nil { + payload.Generic().Output = output + } + return payload +} + +// normalizeShellExecResult populates the ShellExec output from tool result content. +func (n *AmpNormalizer) normalizeShellExecResult(payload *streams.NormalizedPayload, content any, isError bool) { + if payload.ShellExec() == nil { + return + } + output, exitCode := n.parseToolResultContent(content) + payload.ShellExec().Output = &streams.ShellExecOutput{ + Stdout: output, + ExitCode: exitCode, + } + if isError { + payload.ShellExec().Output.Stderr = output + payload.ShellExec().Output.Stdout = "" + } +} + +// normalizeHttpRequestResult populates the HttpRequest response from tool result content. +func (n *AmpNormalizer) normalizeHttpRequestResult(payload *streams.NormalizedPayload, content any) { + if payload.HttpRequest() == nil { + return + } + output, _ := n.parseToolResultContent(content) + payload.HttpRequest().Response = output +} + +// normalizeGenericResult populates the Generic output from tool result content. +func (n *AmpNormalizer) normalizeGenericResult(payload *streams.NormalizedPayload, content any) { + if payload.Generic() == nil { + return + } + output, _ := n.parseToolResultContent(content) + payload.Generic().Output = output +} + +// normalizeReadFileResult populates ReadFile output, converting to CodeSearch for directories. +func (n *AmpNormalizer) normalizeReadFileResult(payload *streams.NormalizedPayload, content any) *streams.NormalizedPayload { + if payload.ReadFile() == nil { + return payload + } + result := n.parseAmpReadResult(content) + if result == nil { + return payload + } + // If it's a directory, convert to code_search + if result.IsDirectory { + return n.buildDirectoryPayload(result) + } + // Regular file read + output := &streams.ReadFileOutput{ + Content: result.Content, + } + if result.Content != "" { + output.LineCount = strings.Count(result.Content, "\n") + 1 + } + payload.ReadFile().Output = output + return payload +} + +// buildDirectoryPayload creates a CodeSearch payload for directory listings. +func (n *AmpNormalizer) buildDirectoryPayload(result *ampReadResult) *streams.NormalizedPayload { + codeSearch := streams.NewCodeSearch( + "", // query + "", // pattern + result.AbsolutePath, // path + "*", // glob - all entries + ) + if codeSearch.CodeSearch() != nil { + codeSearch.CodeSearch().Output = &streams.CodeSearchOutput{ + Files: result.DirectoryEntries, + FileCount: len(result.DirectoryEntries), + } + } + return codeSearch +} + +// normalizeCodeSearchResult populates CodeSearch output from tool result content. +func (n *AmpNormalizer) normalizeCodeSearchResult(payload *streams.NormalizedPayload, content any) { + if payload.CodeSearch() == nil { + return + } + output, _ := n.parseToolResultContent(content) + shared.NormalizeCodeSearchResult(payload.CodeSearch(), output) +} + // ampReadResult holds parsed read result data from Amp. type ampReadResult struct { AbsolutePath string @@ -158,30 +197,34 @@ func (n *AmpNormalizer) parseAmpReadResult(content any) *ampReadResult { return &ReadResult{Content: c} case map[string]any: - result := &ReadResult{} - if v, ok := c["absolutePath"].(string); ok { - result.AbsolutePath = v - } - if v, ok := c["content"].(string); ok { - result.Content = v - } - if v, ok := c["isDirectory"].(bool); ok { - result.IsDirectory = v - } - if v, ok := c["directoryEntries"].([]any); ok { - for _, entry := range v { - if s, ok := entry.(string); ok { - result.DirectoryEntries = append(result.DirectoryEntries, s) - } - } - } - return result + return n.parseAmpReadResultFromMap(c) default: return &ReadResult{} } } +// parseAmpReadResultFromMap parses an Amp read result from a map. +func (n *AmpNormalizer) parseAmpReadResultFromMap(c map[string]any) *ampReadResult { + result := &ReadResult{} + if v, ok := c["absolutePath"].(string); ok { + result.AbsolutePath = v + } + if v, ok := c["content"].(string); ok { + result.Content = v + } + if v, ok := c["isDirectory"].(bool); ok { + result.IsDirectory = v + } + if v, ok := c["directoryEntries"].([]any); ok { + for _, entry := range v { + if s, ok := entry.(string); ok { + result.DirectoryEntries = append(result.DirectoryEntries, s) + } + } + } + return result +} // parseToolResultContent extracts the output string and exit code from Amp's JSON-wrapped content. // Amp sends tool results in formats like: @@ -192,55 +235,69 @@ func (n *AmpNormalizer) parseAmpReadResult(content any) *ampReadResult { func (n *AmpNormalizer) parseToolResultContent(content any) (string, int) { switch c := content.(type) { case string: - // Try to parse as JSON-wrapped content with {output, exitCode} structure - var wrapped struct { - Output string `json:"output"` - ExitCode int `json:"exitCode"` - } - if err := json.Unmarshal([]byte(c), &wrapped); err == nil && wrapped.Output != "" { - // Successfully parsed AND has output field - return wrapped.Output, wrapped.ExitCode - } - // Either not JSON, or JSON without "output" field - use as-is - return c, 0 - + return n.parseStringContent(c) case map[string]any: - // Handle map format (already parsed JSON) - output := shared.GetString(c, "output") - exitCode := shared.GetInt(c, "exitCode") - if output == "" { - // Try alternative field names - output = shared.GetString(c, "stdout") - if stderr := shared.GetString(c, "stderr"); stderr != "" && output == "" { - output = stderr - } - if ec := shared.GetInt(c, "exit_code"); ec != 0 { - exitCode = ec - } - } - return output, exitCode - + return n.parseMapContent(c) case []any: - // Handle array of content blocks (some tools return multiple blocks) - var sb strings.Builder - for _, item := range c { - if str, ok := item.(string); ok { - sb.WriteString(str) - } else if m, ok := item.(map[string]any); ok { - if text := shared.GetString(m, "text"); text != "" { - sb.WriteString(text) - } + return n.parseArrayContent(c), 0 + default: + return n.parseFallbackContent(content), 0 + } +} + +// parseStringContent handles string tool result content. +func (n *AmpNormalizer) parseStringContent(c string) (string, int) { + // Try to parse as JSON-wrapped content with {output, exitCode} structure + var wrapped struct { + Output string `json:"output"` + ExitCode int `json:"exitCode"` + } + if err := json.Unmarshal([]byte(c), &wrapped); err == nil && wrapped.Output != "" { + // Successfully parsed AND has output field + return wrapped.Output, wrapped.ExitCode + } + // Either not JSON, or JSON without "output" field - use as-is + return c, 0 +} + +// parseMapContent handles map tool result content. +func (n *AmpNormalizer) parseMapContent(c map[string]any) (string, int) { + output := shared.GetString(c, "output") + exitCode := shared.GetInt(c, "exitCode") + if output == "" { + // Try alternative field names + output = shared.GetString(c, "stdout") + if output == "" { + output = shared.GetString(c, "stderr") + } + if ec := shared.GetInt(c, "exit_code"); ec != 0 { + exitCode = ec + } + } + return output, exitCode +} + +// parseArrayContent handles array tool result content (multiple content blocks). +func (n *AmpNormalizer) parseArrayContent(c []any) string { + var sb strings.Builder + for _, item := range c { + if str, ok := item.(string); ok { + sb.WriteString(str) + } else if m, ok := item.(map[string]any); ok { + if text := shared.GetString(m, "text"); text != "" { + sb.WriteString(text) } } - return sb.String(), 0 - - default: - // Unknown format, try JSON marshaling for debugging - if data, err := json.Marshal(content); err == nil { - return string(data), 0 - } - return "", 0 } + return sb.String() +} + +// parseFallbackContent handles unknown content types via JSON marshaling. +func (n *AmpNormalizer) parseFallbackContent(content any) string { + if data, err := json.Marshal(content); err == nil { + return string(data) + } + return "" } // normalizeEdit converts Amp Edit/Write tool data. diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go index 278a7392dd..34e2244b29 100644 --- a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go @@ -92,6 +92,9 @@ type resultComplete struct { // defaultCopilotContextWindow is the fallback context window size. const defaultCopilotContextWindow = 128000 +// mcpServerTypeHTTP is the HTTP transport type identifier for MCP servers. +const mcpServerTypeHTTP = "http" + // NewCopilotAdapter creates a new Copilot protocol adapter. func NewCopilotAdapter(cfg *Config, log *logger.Logger) *CopilotAdapter { ctx, cancel := context.WithCancel(context.Background()) @@ -275,8 +278,8 @@ func mcpServersToCopilotConfig(servers []types.McpServer) map[string]copilot.MCP case "sse": cfg["type"] = "sse" cfg["url"] = srv.URL - case "http": - cfg["type"] = "http" + case mcpServerTypeHTTP: + cfg["type"] = mcpServerTypeHTTP cfg["url"] = srv.URL default: // stdio / local cfg["type"] = "local" @@ -505,117 +508,138 @@ func (a *CopilotAdapter) handleEvent(evt copilot.SessionEvent) { sessionID = *evt.Data.SessionID } + if a.handleLifecycleEvent(evt, sessionID, operationID) { + return + } + a.handleContentEvent(evt, sessionID, operationID) +} + +// handleLifecycleEvent handles session lifecycle events. +// Returns true if the event was handled. +func (a *CopilotAdapter) handleLifecycleEvent(evt copilot.SessionEvent, sessionID, operationID string) bool { switch evt.Type { case copilot.EventTypeSessionStart: a.logger.Info("session started", zap.String("session_id", sessionID)) - case copilot.EventTypeSessionResume: a.logger.Info("session resumed", zap.String("session_id", sessionID)) - - case copilot.EventTypeAssistantMessage: - // Full message (non-streaming) - only accumulate if we haven't received deltas - // When streaming is used, deltas build up the text incrementally, and the final - // assistant.message contains the full text again - skip to avoid duplication - if evt.Data.Content != nil && *evt.Data.Content != "" { - a.mu.Lock() - alreadyReceivedDeltas := a.receivedDeltas - if !alreadyReceivedDeltas { - // Non-streaming mode: accumulate the full message - a.textAccumulator.WriteString(*evt.Data.Content) - } - a.mu.Unlock() - - // Only send message chunk if we haven't been streaming - if !alreadyReceivedDeltas { - a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: *evt.Data.Content, - }) - } - } - - case copilot.EventTypeAssistantMessageDelta: - // Streaming message delta - if evt.Data.DeltaContent != nil && *evt.Data.DeltaContent != "" { - text := *evt.Data.DeltaContent - // Accumulate text locally to include in complete event - a.mu.Lock() - a.textAccumulator.WriteString(text) - a.receivedDeltas = true // Mark that we've received streaming deltas - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: text, - }) - } - - case copilot.EventTypeAssistantReasoning, copilot.EventTypeAssistantReasoningDelta: - // Reasoning/thinking content - var content string - if evt.Data.Content != nil { - content = *evt.Data.Content - } - if content == "" && evt.Data.DeltaContent != nil { - content = *evt.Data.DeltaContent - } - if content != "" { - a.sendUpdate(AgentEvent{ - Type: EventTypeReasoning, - SessionID: sessionID, - OperationID: operationID, - ReasoningText: content, - }) - } - - case copilot.EventTypeToolStart: - a.handleToolStart(evt, sessionID, operationID) - - case copilot.EventTypeToolComplete: - a.handleToolComplete(evt, sessionID, operationID) - - case copilot.EventTypeToolProgress: - // Tool progress update - toolCallID := "" - if evt.Data.ToolCallID != nil { - toolCallID = *evt.Data.ToolCallID - } - a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolStatus: "running", - }) - - case copilot.EventTypeSessionIdle: - a.handleSessionIdle(sessionID, operationID) - - case copilot.EventTypeSessionError: - a.handleSessionError(evt, sessionID, operationID) - - case copilot.EventTypeSessionUsageInfo, copilot.EventTypeAssistantUsage: - a.handleUsageInfo(evt, sessionID, operationID) - case copilot.EventTypeAssistantTurnStart: a.logger.Debug("turn started", zap.String("operation_id", operationID)) - case copilot.EventTypeAssistantTurnEnd: a.logger.Debug("turn ended", zap.String("operation_id", operationID)) - case copilot.EventTypeAbort: a.logger.Info("operation aborted") a.signalCompletion(false, "operation aborted") + default: + return false + } + return true +} +// handleContentEvent handles content and tool events. +func (a *CopilotAdapter) handleContentEvent(evt copilot.SessionEvent, sessionID, operationID string) { + switch evt.Type { + case copilot.EventTypeAssistantMessage: + a.handleAssistantMessage(evt, sessionID, operationID) + case copilot.EventTypeAssistantMessageDelta: + a.handleAssistantMessageDelta(evt, sessionID, operationID) + case copilot.EventTypeAssistantReasoning, copilot.EventTypeAssistantReasoningDelta: + a.handleAssistantReasoning(evt, sessionID, operationID) + case copilot.EventTypeToolStart: + a.handleToolStart(evt, sessionID, operationID) + case copilot.EventTypeToolComplete: + a.handleToolComplete(evt, sessionID, operationID) + case copilot.EventTypeToolProgress: + a.handleToolProgress(evt, sessionID, operationID) + case copilot.EventTypeSessionIdle: + a.handleSessionIdle(sessionID, operationID) + case copilot.EventTypeSessionError: + a.handleSessionError(evt, sessionID, operationID) + case copilot.EventTypeSessionUsageInfo, copilot.EventTypeAssistantUsage: + a.handleUsageInfo(evt, sessionID, operationID) default: a.logger.Debug("unhandled SDK event", zap.String("type", string(evt.Type))) } } +// handleAssistantMessage handles a full (non-streaming) assistant message event. +// It skips accumulation if streaming deltas were already received to avoid duplication. +func (a *CopilotAdapter) handleAssistantMessage(evt copilot.SessionEvent, sessionID, operationID string) { + if evt.Data.Content == nil || *evt.Data.Content == "" { + return + } + a.mu.Lock() + alreadyReceivedDeltas := a.receivedDeltas + if !alreadyReceivedDeltas { + // Non-streaming mode: accumulate the full message + a.textAccumulator.WriteString(*evt.Data.Content) + } + a.mu.Unlock() + + // Only send message chunk if we haven't been streaming + if !alreadyReceivedDeltas { + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: *evt.Data.Content, + }) + } +} + +// handleAssistantMessageDelta handles a streaming message delta event. +func (a *CopilotAdapter) handleAssistantMessageDelta(evt copilot.SessionEvent, sessionID, operationID string) { + if evt.Data.DeltaContent == nil || *evt.Data.DeltaContent == "" { + return + } + text := *evt.Data.DeltaContent + // Accumulate text locally to include in complete event + a.mu.Lock() + a.textAccumulator.WriteString(text) + a.receivedDeltas = true // Mark that we've received streaming deltas + a.mu.Unlock() + + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: text, + }) +} + +// handleAssistantReasoning handles reasoning/thinking content events. +func (a *CopilotAdapter) handleAssistantReasoning(evt copilot.SessionEvent, sessionID, operationID string) { + var content string + if evt.Data.Content != nil { + content = *evt.Data.Content + } + if content == "" && evt.Data.DeltaContent != nil { + content = *evt.Data.DeltaContent + } + if content != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeReasoning, + SessionID: sessionID, + OperationID: operationID, + ReasoningText: content, + }) + } +} + +// handleToolProgress handles tool progress update events. +func (a *CopilotAdapter) handleToolProgress(evt copilot.SessionEvent, sessionID, operationID string) { + toolCallID := "" + if evt.Data.ToolCallID != nil { + toolCallID = *evt.Data.ToolCallID + } + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolStatus: "running", + }) +} + // handleToolStart processes tool execution start events. func (a *CopilotAdapter) handleToolStart(evt copilot.SessionEvent, sessionID, operationID string) { toolCallID := "" @@ -870,73 +894,7 @@ func (a *CopilotAdapter) handlePermissionRequest( return copilot.PermissionRequestResult{Kind: "approved"}, nil } - // Build a human-readable title using cached tool data if available. - // The pendingToolPayloads map is populated by handleToolStart events - // which run in a separate goroutine from the permission handler. - // Wait briefly for the tool_call event to be processed first, ensuring - // the tool_call message is created in the DB before the permission message - // (needed for the frontend merge logic to work). - title := request.Kind - actionType := request.Kind - actionDetails := request.Extra - - var cachedPayload *streams.NormalizedPayload - for i := 0; i < 10; i++ { - a.mu.RLock() - cachedPayload = a.pendingToolPayloads[request.ToolCallID] - a.mu.RUnlock() - if cachedPayload != nil { - break - } - time.Sleep(50 * time.Millisecond) - } - - if cachedPayload != nil { - switch cachedPayload.Kind() { - case streams.ToolKindShellExec: - if se := cachedPayload.ShellExec(); se != nil { - title = se.Command - actionType = types.ActionTypeCommand - actionDetails = map[string]interface{}{ - "command": se.Command, - "cwd": se.WorkDir, - } - } - case streams.ToolKindModifyFile: - if mf := cachedPayload.ModifyFile(); mf != nil { - title = fmt.Sprintf("Write: %s", mf.FilePath) - actionType = types.ActionTypeFileWrite - actionDetails = map[string]interface{}{ - "path": mf.FilePath, - } - } - case streams.ToolKindReadFile: - if rf := cachedPayload.ReadFile(); rf != nil { - title = fmt.Sprintf("Read: %s", rf.FilePath) - actionType = types.ActionTypeFileRead - actionDetails = map[string]interface{}{ - "path": rf.FilePath, - } - } - default: - // For other tool kinds, use a generic label - title = string(cachedPayload.Kind()) - } - } - - // Convert Copilot permission request to kandev format - permReq := &PermissionRequest{ - SessionID: sessionID, - ToolCallID: request.ToolCallID, - Title: title, - ActionType: actionType, - ActionDetails: actionDetails, - PendingID: request.ToolCallID, - Options: []PermissionOption{ - {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, - {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, - }, - } + permReq := a.buildPermissionRequest(request, sessionID) a.logger.Info("requesting permission from user", zap.String("kind", request.Kind), @@ -966,6 +924,86 @@ func (a *CopilotAdapter) handlePermissionRequest( return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil } +// buildPermissionRequest constructs a PermissionRequest from a Copilot SDK request, +// waiting briefly for cached tool payload data to populate action details. +func (a *CopilotAdapter) buildPermissionRequest(request copilot.PermissionRequest, sessionID string) *PermissionRequest { + // Build a human-readable title using cached tool data if available. + // The pendingToolPayloads map is populated by handleToolStart events + // which run in a separate goroutine from the permission handler. + // Wait briefly for the tool_call event to be processed first, ensuring + // the tool_call message is created in the DB before the permission message + // (needed for the frontend merge logic to work). + title := request.Kind + actionType := request.Kind + actionDetails := request.Extra + + var cachedPayload *streams.NormalizedPayload + for i := 0; i < 10; i++ { + a.mu.RLock() + cachedPayload = a.pendingToolPayloads[request.ToolCallID] + a.mu.RUnlock() + if cachedPayload != nil { + break + } + time.Sleep(50 * time.Millisecond) + } + + if cachedPayload != nil { + var newDetails interface{} + title, actionType, newDetails = a.resolvePermissionActionDetails(cachedPayload, title, actionType, actionDetails) + if m, ok := newDetails.(map[string]interface{}); ok { + actionDetails = m + } + } + + return &PermissionRequest{ + SessionID: sessionID, + ToolCallID: request.ToolCallID, + Title: title, + ActionType: actionType, + ActionDetails: actionDetails, + PendingID: request.ToolCallID, + Options: []PermissionOption{ + {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, + {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, + }, + } +} + +// resolvePermissionActionDetails maps a cached tool payload to a human-readable title, +// action type, and action details for use in a permission request. +func (a *CopilotAdapter) resolvePermissionActionDetails( + cachedPayload *streams.NormalizedPayload, + title, actionType string, + actionDetails map[string]interface{}, +) (string, string, map[string]interface{}) { + switch cachedPayload.Kind() { + case streams.ToolKindShellExec: + if se := cachedPayload.ShellExec(); se != nil { + return se.Command, types.ActionTypeCommand, map[string]interface{}{ + "command": se.Command, + "cwd": se.WorkDir, + } + } + case streams.ToolKindModifyFile: + if mf := cachedPayload.ModifyFile(); mf != nil { + return fmt.Sprintf("Write: %s", mf.FilePath), types.ActionTypeFileWrite, map[string]interface{}{ + "path": mf.FilePath, + } + } + case streams.ToolKindReadFile: + if rf := cachedPayload.ReadFile(); rf != nil { + return fmt.Sprintf("Read: %s", rf.FilePath), types.ActionTypeFileRead, map[string]interface{}{ + "path": rf.FilePath, + } + } + default: + // For other tool kinds, use a generic label + return string(cachedPayload.Kind()), actionType, actionDetails + } + return title, actionType, actionDetails +} + // RequiresProcessKill returns true because the Copilot CLI server doesn't exit on stdin close. // The CLI runs as an HTTP server that must be explicitly killed. func (a *CopilotAdapter) RequiresProcessKill() bool { diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_adapter_test.go b/apps/backend/internal/agentctl/server/adapter/copilot_adapter_test.go index a66fd80444..6aa754bebf 100644 --- a/apps/backend/internal/agentctl/server/adapter/copilot_adapter_test.go +++ b/apps/backend/internal/agentctl/server/adapter/copilot_adapter_test.go @@ -110,14 +110,14 @@ func TestMcpServersToCopilotConfig_HTTPServer(t *testing.T) { servers := []types.McpServer{ { Name: "my-http-server", - Type: "http", + Type: mcpServerTypeHTTP, URL: "http://localhost:9090/mcp", }, } result := mcpServersToCopilotConfig(servers) cfg := result["my-http-server"] - assertConfigValue(t, cfg, "type", "http") + assertConfigValue(t, cfg, "type", mcpServerTypeHTTP) assertConfigValue(t, cfg, "url", "http://localhost:9090/mcp") } @@ -136,7 +136,7 @@ func TestMcpServersToCopilotConfig_MultipleServers(t *testing.T) { }, { Name: "http-server", - Type: "http", + Type: mcpServerTypeHTTP, URL: "http://localhost:9090/mcp", }, } @@ -154,7 +154,7 @@ func TestMcpServersToCopilotConfig_MultipleServers(t *testing.T) { assertConfigValue(t, result["sse-server"], "type", "sse") assertConfigValue(t, result["sse-server"], "url", "http://localhost:8080/sse") - assertConfigValue(t, result["http-server"], "type", "http") + assertConfigValue(t, result["http-server"], "type", mcpServerTypeHTTP) assertConfigValue(t, result["http-server"], "url", "http://localhost:9090/mcp") } @@ -180,7 +180,7 @@ func TestMcpServersToCopilotConfig_AllHaveWildcardTools(t *testing.T) { servers := []types.McpServer{ {Name: "a", Type: "stdio", Command: "cmd-a"}, {Name: "b", Type: "sse", URL: "http://b"}, - {Name: "c", Type: "http", URL: "http://c"}, + {Name: "c", Type: mcpServerTypeHTTP, URL: "http://c"}, } result := mcpServersToCopilotConfig(servers) diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go b/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go index cd07d5f1df..e6a541a873 100644 --- a/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go +++ b/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go @@ -53,7 +53,7 @@ func (n *CopilotNormalizer) NormalizeToolCall(toolName string, args any) *stream return n.normalizeEdit(argsMap) case CopilotToolGlob, CopilotToolGrep, "search", "find": return n.normalizeCodeSearch(toolNameLower, argsMap) - case CopilotToolWebFetch, CopilotToolWebSearch, "fetch", "http": + case CopilotToolWebFetch, CopilotToolWebSearch, "fetch", mcpServerTypeHTTP: return n.normalizeHttpRequest(toolNameLower, argsMap) default: return n.normalizeGeneric(toolName, argsMap) @@ -63,63 +63,96 @@ func (n *CopilotNormalizer) NormalizeToolCall(toolName string, args any) *stream // NormalizeToolResult updates the payload with tool result data. func (n *CopilotNormalizer) NormalizeToolResult(cachedPayload *streams.NormalizedPayload, result any, isError bool) *streams.NormalizedPayload { if cachedPayload == nil { - // No cached payload, create a generic one with the result - output := n.extractOutput(result) - payload := streams.NewGeneric("unknown", nil) - if payload.Generic() != nil { - payload.Generic().Output = output - } - return payload + return n.normalizeResultNoCached(result) } - // Update the cached payload with the result switch cachedPayload.Kind() { case streams.ToolKindShellExec: - if cachedPayload.ShellExec() != nil { - output, exitCode := n.parseShellResult(result) - cachedPayload.ShellExec().Output = &streams.ShellExecOutput{ - Stdout: output, - ExitCode: exitCode, - } - if isError { - cachedPayload.ShellExec().Output.Stderr = output - cachedPayload.ShellExec().Output.Stdout = "" - } - } + n.normalizeShellExecResult(cachedPayload, result, isError) case streams.ToolKindHttpRequest: - if cachedPayload.HttpRequest() != nil { - if output, ok := n.extractOutput(result).(string); ok { - cachedPayload.HttpRequest().Response = output - } - } + n.normalizeHttpRequestResult(cachedPayload, result) case streams.ToolKindGeneric: - if cachedPayload.Generic() != nil { - cachedPayload.Generic().Output = n.extractOutput(result) - } + n.normalizeGenericResult(cachedPayload, result) case streams.ToolKindReadFile: - if cachedPayload.ReadFile() != nil { - if output, ok := n.extractOutput(result).(string); ok { - cachedPayload.ReadFile().Output = &streams.ReadFileOutput{ - Content: output, - } - } - } + n.normalizeReadFileResult(cachedPayload, result) case streams.ToolKindCodeSearch: - if cachedPayload.CodeSearch() != nil { - if output, ok := n.extractOutput(result).(string); ok { - // Parse file list from newline-separated output - files := n.parseFileList(output) - cachedPayload.CodeSearch().Output = &streams.CodeSearchOutput{ - Files: files, - FileCount: len(files), - } - } - } + n.normalizeCodeSearchResult(cachedPayload, result) } return cachedPayload } +// normalizeResultNoCached creates a generic payload when no cached payload exists. +func (n *CopilotNormalizer) normalizeResultNoCached(result any) *streams.NormalizedPayload { + output := n.extractOutput(result) + payload := streams.NewGeneric("unknown", nil) + if payload.Generic() != nil { + payload.Generic().Output = output + } + return payload +} + +// normalizeShellExecResult populates ShellExec output from tool result. +func (n *CopilotNormalizer) normalizeShellExecResult(payload *streams.NormalizedPayload, result any, isError bool) { + if payload.ShellExec() == nil { + return + } + output, exitCode := n.parseShellResult(result) + payload.ShellExec().Output = &streams.ShellExecOutput{ + Stdout: output, + ExitCode: exitCode, + } + if isError { + payload.ShellExec().Output.Stderr = output + payload.ShellExec().Output.Stdout = "" + } +} + +// normalizeHttpRequestResult populates HttpRequest response from tool result. +func (n *CopilotNormalizer) normalizeHttpRequestResult(payload *streams.NormalizedPayload, result any) { + if payload.HttpRequest() == nil { + return + } + if output, ok := n.extractOutput(result).(string); ok { + payload.HttpRequest().Response = output + } +} + +// normalizeGenericResult populates Generic output from tool result. +func (n *CopilotNormalizer) normalizeGenericResult(payload *streams.NormalizedPayload, result any) { + if payload.Generic() == nil { + return + } + payload.Generic().Output = n.extractOutput(result) +} + +// normalizeReadFileResult populates ReadFile output from tool result. +func (n *CopilotNormalizer) normalizeReadFileResult(payload *streams.NormalizedPayload, result any) { + if payload.ReadFile() == nil { + return + } + if output, ok := n.extractOutput(result).(string); ok { + payload.ReadFile().Output = &streams.ReadFileOutput{ + Content: output, + } + } +} + +// normalizeCodeSearchResult populates CodeSearch output from tool result. +func (n *CopilotNormalizer) normalizeCodeSearchResult(payload *streams.NormalizedPayload, result any) { + if payload.CodeSearch() == nil { + return + } + if output, ok := n.extractOutput(result).(string); ok { + // Parse file list from newline-separated output + files := n.parseFileList(output) + payload.CodeSearch().Output = &streams.CodeSearchOutput{ + Files: files, + FileCount: len(files), + } + } +} + // toMap converts an argument to a map[string]any. func (n *CopilotNormalizer) toMap(args any) map[string]any { if args == nil { @@ -138,6 +171,16 @@ func (n *CopilotNormalizer) toMap(args any) map[string]any { return nil } +// extractStringFieldFromMap tries common output field names on a map. +func extractStringFieldFromMap(m map[string]any) (string, bool) { + for _, key := range []string{"output", "stdout", "content", "text"} { + if v, ok := m[key].(string); ok { + return v, true + } + } + return "", false +} + // extractOutput extracts a string output from various result formats. func (n *CopilotNormalizer) extractOutput(result any) any { if result == nil { @@ -148,42 +191,29 @@ func (n *CopilotNormalizer) extractOutput(result any) any { case string: return r case map[string]any: - // Try common output field names - if output, ok := r["output"].(string); ok { - return output - } - if stdout, ok := r["stdout"].(string); ok { - return stdout - } - if content, ok := r["content"].(string); ok { - return content - } - if text, ok := r["text"].(string); ok { - return text + if v, ok := extractStringFieldFromMap(r); ok { + return v } return r default: - // Handle struct types (like Copilot SDK's *Result) via JSON round-trip - if data, err := json.Marshal(result); err == nil { - var m map[string]any - if err := json.Unmarshal(data, &m); err == nil { - // Try to extract content from the unmarshaled map - if content, ok := m["content"].(string); ok { - return content - } - if output, ok := m["output"].(string); ok { - return output - } - if stdout, ok := m["stdout"].(string); ok { - return stdout - } - if text, ok := m["text"].(string); ok { - return text - } - } - } + return n.extractOutputFromStruct(result) + } +} + +// extractOutputFromStruct handles struct types (like Copilot SDK's *Result) via JSON round-trip. +func (n *CopilotNormalizer) extractOutputFromStruct(result any) any { + data, err := json.Marshal(result) + if err != nil { return result } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return result + } + if v, ok := extractStringFieldFromMap(m); ok { + return v + } + return result } // parseShellResult extracts output and exit code from shell execution result. @@ -199,41 +229,9 @@ func (n *CopilotNormalizer) parseShellResult(result any) (string, int) { case string: output = r case map[string]any: - // Try various output field names used by different tools - output = shared.GetString(r, "output") - if output == "" { - output = shared.GetString(r, "stdout") - } - if output == "" { - output = shared.GetString(r, "content") - } - if output == "" { - output = shared.GetString(r, "text") - } - exitCode = shared.GetInt(r, "exitCode") - if exitCode == 0 { - exitCode = shared.GetInt(r, "exit_code") - } + output, exitCode = n.parseShellResultFromMap(r) default: - // Handle struct types (like Copilot SDK's *Result) via JSON round-trip - if data, err := json.Marshal(result); err == nil { - var m map[string]any - if err := json.Unmarshal(data, &m); err == nil { - // Try to extract content from the unmarshaled map - if content, ok := m["content"].(string); ok { - output = content - } else if out, ok := m["output"].(string); ok { - output = out - } else if stdout, ok := m["stdout"].(string); ok { - output = stdout - } else { - // Fallback: return the JSON string - output = string(data) - } - } else { - output = string(data) - } - } + output, exitCode = n.parseShellResultFromStruct(result) } // Strip Copilot's "" suffix and extract exit code @@ -242,6 +240,47 @@ func (n *CopilotNormalizer) parseShellResult(result any) (string, int) { return output, exitCode } +// parseShellResultFromMap extracts output and exit code from a map result. +func (n *CopilotNormalizer) parseShellResultFromMap(r map[string]any) (string, int) { + output := shared.GetString(r, "output") + if output == "" { + output = shared.GetString(r, "stdout") + } + if output == "" { + output = shared.GetString(r, "content") + } + if output == "" { + output = shared.GetString(r, "text") + } + exitCode := shared.GetInt(r, "exitCode") + if exitCode == 0 { + exitCode = shared.GetInt(r, "exit_code") + } + return output, exitCode +} + +// parseShellResultFromStruct handles struct types via JSON round-trip. +func (n *CopilotNormalizer) parseShellResultFromStruct(result any) (string, int) { + data, err := json.Marshal(result) + if err != nil { + return "", 0 + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return string(data), 0 + } + if content, ok := m["content"].(string); ok { + return content, 0 + } + if out, ok := m["output"].(string); ok { + return out, 0 + } + if stdout, ok := m["stdout"].(string); ok { + return stdout, 0 + } + return string(data), 0 +} + // cleanShellOutput strips Copilot's "" suffix from shell output // and extracts the exit code if not already set. func (n *CopilotNormalizer) cleanShellOutput(output string, exitCode int) (string, int) { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index ea229fc160..6b7d046d33 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -458,15 +458,15 @@ func (a *Adapter) handleACPUpdate(n acp.SessionNotification) { // convertNotification converts an ACP SessionNotification to an AgentEvent. func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { u := n.Update + sessionID := string(n.SessionId) switch { case u.AgentMessageChunk != nil: if u.AgentMessageChunk.Content.Text != nil { - text := u.AgentMessageChunk.Content.Text.Text return &AgentEvent{ Type: streams.EventTypeMessageChunk, - SessionID: string(n.SessionId), - Text: text, + SessionID: sessionID, + Text: u.AgentMessageChunk.Content.Text.Text, } } @@ -474,114 +474,18 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { // Agent thinking/reasoning - map to the reasoning type // Note: Only models with extended thinking (e.g., Opus 4.5) send agent_thought_chunk if u.AgentThoughtChunk.Content.Text != nil { - text := u.AgentThoughtChunk.Content.Text.Text return &AgentEvent{ Type: streams.EventTypeReasoning, - SessionID: string(n.SessionId), - ReasoningText: text, + SessionID: sessionID, + ReasoningText: u.AgentThoughtChunk.Content.Text.Text, } } case u.ToolCall != nil: - // Extract rich tool call information - args := map[string]any{} - - // Add tool kind - if u.ToolCall.Kind != "" { - args["kind"] = string(u.ToolCall.Kind) - } - - // Add locations (file paths with line numbers) - if len(u.ToolCall.Locations) > 0 { - locations := make([]map[string]any, len(u.ToolCall.Locations)) - for i, loc := range u.ToolCall.Locations { - locMap := map[string]any{"path": loc.Path} - if loc.Line != nil { - locMap["line"] = *loc.Line - } - locations[i] = locMap - } - args["locations"] = locations - - // Also set primary path for convenience - args["path"] = u.ToolCall.Locations[0].Path - } - - // Add raw input if available - if u.ToolCall.RawInput != nil { - args["raw_input"] = u.ToolCall.RawInput - } - - // Generate normalized payload using the normalizer - toolKind := string(u.ToolCall.Kind) - normalizedPayload := a.normalizer.NormalizeToolCall(toolKind, args) - - // Store the payload for later result updates - toolCallID := string(u.ToolCall.ToolCallId) - a.mu.Lock() - a.activeToolCalls[toolCallID] = normalizedPayload - a.mu.Unlock() - - // Detect tool type for logging - toolType := DetectToolOperationType(toolKind, args) - _ = toolType // Used for normalization - - // Normalize status - if empty, default to "running" for tool_call start - status := string(u.ToolCall.Status) - if status == "" { - status = "running" - } - - return &AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: string(n.SessionId), - ToolCallID: toolCallID, - ToolName: string(u.ToolCall.Kind), // Kind is effectively the tool name - ToolTitle: u.ToolCall.Title, - ToolStatus: status, - NormalizedPayload: normalizedPayload, - } + return a.convertToolCallUpdate(sessionID, u.ToolCall) case u.ToolCallUpdate != nil: - toolCallID := string(u.ToolCallUpdate.ToolCallId) - status := "" - if u.ToolCallUpdate.Status != nil { - status = string(*u.ToolCallUpdate.Status) - } - // Normalize status - "completed" -> "complete" for frontend consistency - if status == "completed" { - status = "complete" - } - - // Look up the stored payload and update with result if we have rawOutput - var normalizedPayload *streams.NormalizedPayload - if u.ToolCallUpdate.RawOutput != nil { - a.mu.Lock() - if payload, ok := a.activeToolCalls[toolCallID]; ok { - // Update the payload with the result - a.normalizer.NormalizeToolResult(payload, u.ToolCallUpdate.RawOutput) - normalizedPayload = payload - // Clean up if completed - if status == "complete" || status == "error" { - delete(a.activeToolCalls, toolCallID) - } - } - a.mu.Unlock() - } else if status == "complete" || status == "error" { - // Clean up even without output - a.mu.Lock() - normalizedPayload = a.activeToolCalls[toolCallID] - delete(a.activeToolCalls, toolCallID) - a.mu.Unlock() - } - - return &AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: string(n.SessionId), - ToolCallID: toolCallID, - ToolStatus: status, - NormalizedPayload: normalizedPayload, - } + return a.convertToolCallResultUpdate(sessionID, u.ToolCallUpdate) case u.Plan != nil: entries := make([]PlanEntry, len(u.Plan.Entries)) @@ -594,7 +498,7 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { } return &AgentEvent{ Type: streams.EventTypePlan, - SessionID: string(n.SessionId), + SessionID: sessionID, PlanEntries: entries, } @@ -608,7 +512,7 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { } return &AgentEvent{ Type: streams.EventTypeAvailableCommands, - SessionID: string(n.SessionId), + SessionID: sessionID, AvailableCommands: commands, } } @@ -616,6 +520,100 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { return nil } +// convertToolCallUpdate converts a ToolCall notification to an AgentEvent. +func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateToolCall) *AgentEvent { + args := map[string]any{} + + if tc.Kind != "" { + args["kind"] = string(tc.Kind) + } + + if len(tc.Locations) > 0 { + locations := make([]map[string]any, len(tc.Locations)) + for i, loc := range tc.Locations { + locMap := map[string]any{"path": loc.Path} + if loc.Line != nil { + locMap["line"] = *loc.Line + } + locations[i] = locMap + } + args["locations"] = locations + args["path"] = tc.Locations[0].Path + } + + if tc.RawInput != nil { + args["raw_input"] = tc.RawInput + } + + toolKind := string(tc.Kind) + normalizedPayload := a.normalizer.NormalizeToolCall(toolKind, args) + + toolCallID := string(tc.ToolCallId) + a.mu.Lock() + a.activeToolCalls[toolCallID] = normalizedPayload + a.mu.Unlock() + + // Detect tool type for logging + toolType := DetectToolOperationType(toolKind, args) + _ = toolType // Used for normalization + + status := string(tc.Status) + if status == "" { + status = "running" + } + + return &AgentEvent{ + Type: streams.EventTypeToolCall, + SessionID: sessionID, + ToolCallID: toolCallID, + ToolName: toolKind, // Kind is effectively the tool name + ToolTitle: tc.Title, + ToolStatus: status, + NormalizedPayload: normalizedPayload, + } +} + +// convertToolCallResultUpdate converts a ToolCallUpdate notification to an AgentEvent. +func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.SessionToolCallUpdate) *AgentEvent { + toolCallID := string(tcu.ToolCallId) + status := "" + if tcu.Status != nil { + status = string(*tcu.Status) + } + // Normalize status - "completed" -> "complete" for frontend consistency + if status == "completed" { + status = toolStatusComplete + } + + // Look up the stored payload and update with result if we have rawOutput + var normalizedPayload *streams.NormalizedPayload + if tcu.RawOutput != nil { + a.mu.Lock() + if payload, ok := a.activeToolCalls[toolCallID]; ok { + a.normalizer.NormalizeToolResult(payload, tcu.RawOutput) + normalizedPayload = payload + if status == toolStatusComplete || status == toolStatusError { + delete(a.activeToolCalls, toolCallID) + } + } + a.mu.Unlock() + } else if status == toolStatusComplete || status == toolStatusError { + // Clean up even without output + a.mu.Lock() + normalizedPayload = a.activeToolCalls[toolCallID] + delete(a.activeToolCalls, toolCallID) + a.mu.Unlock() + } + + return &AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + ToolCallID: toolCallID, + ToolStatus: status, + NormalizedPayload: normalizedPayload, + } +} + // handlePermissionRequest handles permission requests from the agent. // Since both acpclient and adapter now use the shared types package, // no conversion is needed - we just forward to the handler. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go index 0b8473c903..1c2b3cec78 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go @@ -8,39 +8,58 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" ) +// Tool operation type constants. +const ( + toolKindEdit = "edit" + toolKindRead = "read" + toolKindExecute = "execute" + toolKindGlob = "glob" + toolKindGrep = "grep" + toolKindSearch = "search" + + toolTypeEdit = "tool_edit" + toolTypeRead = "tool_read" + toolTypeExecute = "tool_execute" + toolTypeSearch = "tool_search" + toolTypeGeneric = "tool_call" + + toolStatusComplete = "complete" + toolStatusError = "error" +) + // DetectToolOperationType determines the specific tool operation type from ACP tool data. // Used for logging and backwards compatibility. func DetectToolOperationType(toolKind string, args map[string]any) string { // Check Auggie's "kind" field first if kind, ok := args["kind"].(string); ok { switch kind { - case "edit": - return "tool_edit" - case "read": + case toolKindEdit: + return toolTypeEdit + case toolKindRead: // Check if this is a directory read (file listing) if rawInput, ok := args["raw_input"].(map[string]any); ok { if readType, ok := rawInput["type"].(string); ok && readType == "directory" { - return "tool_search" + return toolTypeSearch } } - return "tool_read" - case "execute": - return "tool_execute" + return toolTypeRead + case toolKindExecute: + return toolTypeExecute } } // Fallback to tool kind/name matching switch strings.ToLower(toolKind) { - case "edit": - return "tool_edit" - case "read", "view": - return "tool_read" - case "execute", "bash", "run": - return "tool_execute" - case "glob", "grep", "search": - return "tool_search" + case toolKindEdit: + return toolTypeEdit + case toolKindRead, "view": + return toolTypeRead + case toolKindExecute, "bash", "run": + return toolTypeExecute + case toolKindGlob, toolKindGrep, toolKindSearch: + return toolTypeSearch default: - return "tool_call" // Generic fallback + return toolTypeGeneric // Generic fallback (intentional: different from tool type constants) } } @@ -61,13 +80,13 @@ func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *st } switch strings.ToLower(kind) { - case "edit": + case toolKindEdit: return n.normalizeEdit(args) - case "read", "view": + case toolKindRead, "view": return n.normalizeRead(args) - case "execute", "bash", "run", "shell": + case toolKindExecute, "bash", "run", "shell": return n.normalizeExecute(args) - case "glob", "grep", "search": + case toolKindGlob, toolKindGrep, toolKindSearch: return n.normalizeCodeSearch(toolName, args) default: return n.normalizeGeneric(toolName, args) @@ -305,9 +324,9 @@ func (n *Normalizer) normalizeCodeSearch(toolName string, args map[string]any) * var query, glob string switch strings.ToLower(toolName) { - case "glob": + case toolKindGlob: glob = pattern - case "grep", "search": + case toolKindGrep, toolKindSearch: query = shared.GetString(rawInput, "query") } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go index 74646aefe1..e50c8006ef 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go @@ -31,23 +31,23 @@ func TestACPNormalization(t *testing.T) { // Verify the Kind is set correctly based on tool type switch toolType { - case "tool_edit": + case toolTypeEdit: if payload.Kind() != streams.ToolKindModifyFile { t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, payload.Kind()) } - case "tool_read": + case toolTypeRead: if payload.Kind() != streams.ToolKindReadFile { t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, payload.Kind()) } - case "tool_execute": + case toolTypeExecute: if payload.Kind() != streams.ToolKindShellExec { t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, payload.Kind()) } - case "tool_search": + case toolTypeSearch: if payload.Kind() != streams.ToolKindCodeSearch { t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, payload.Kind()) } - case "tool_call": + case toolTypeGeneric: if payload.Kind() != streams.ToolKindGeneric { t.Errorf("expected Kind %q, got %q", streams.ToolKindGeneric, payload.Kind()) } @@ -68,73 +68,73 @@ func TestDetectToolOperationType(t *testing.T) { name: "edit kind from args", toolKind: "", args: map[string]any{"kind": "edit"}, - want: "tool_edit", + want: toolTypeEdit, }, { name: "read kind from args", toolKind: "", args: map[string]any{"kind": "read"}, - want: "tool_read", + want: toolTypeRead, }, { name: "execute kind from args", toolKind: "", args: map[string]any{"kind": "execute"}, - want: "tool_execute", + want: toolTypeExecute, }, { name: "edit from toolKind parameter", toolKind: "edit", args: map[string]any{}, - want: "tool_edit", + want: toolTypeEdit, }, { name: "read from toolKind parameter", toolKind: "read", args: map[string]any{}, - want: "tool_read", + want: toolTypeRead, }, { name: "view from toolKind parameter", toolKind: "view", args: map[string]any{}, - want: "tool_read", + want: toolTypeRead, }, { name: "bash from toolKind parameter", toolKind: "bash", args: map[string]any{}, - want: "tool_execute", + want: toolTypeExecute, }, { name: "run from toolKind parameter", toolKind: "run", args: map[string]any{}, - want: "tool_execute", + want: toolTypeExecute, }, { name: "search kind returns tool_search", toolKind: "search", args: map[string]any{"kind": "search"}, - want: "tool_search", + want: toolTypeSearch, }, { name: "unknown kind falls back to tool_call", toolKind: "custom_tool", args: map[string]any{"kind": "custom_tool"}, - want: "tool_call", + want: toolTypeGeneric, }, { name: "empty kind and args falls back to tool_call", toolKind: "", args: map[string]any{}, - want: "tool_call", + want: toolTypeGeneric, }, { name: "args kind takes priority over toolKind", toolKind: "read", args: map[string]any{"kind": "edit"}, - want: "tool_edit", + want: toolTypeEdit, }, { name: "read with directory type returns tool_search", @@ -145,7 +145,7 @@ func TestDetectToolOperationType(t *testing.T) { "type": "directory", }, }, - want: "tool_search", + want: toolTypeSearch, }, } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go index 08a0bff4b1..d44e25a9f2 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go @@ -4,25 +4,27 @@ package codex import ( "context" - "encoding/base64" "encoding/json" "fmt" "io" - "os" - "path/filepath" - "strings" "sync" - "github.com/google/uuid" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" - v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/pkg/codex" "go.uber.org/zap" ) +// Codex decision constants for approval responses. +const ( + decisionAccept = "accept" + decisionAcceptSession = "acceptForSession" + decisionDecline = "decline" + decisionCancel = "cancel" +) + // Re-export types needed by external packages type ( PermissionRequest = types.PermissionRequest @@ -117,60 +119,6 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { } } -// PrepareEnvironment is a no-op for Codex. MCP servers and sandbox settings -// are now passed via command-line -c flags through PrepareCommandArgs(). -func (a *Adapter) PrepareEnvironment() (map[string]string, error) { - a.logger.Info("PrepareEnvironment called (no-op for Codex)") - return nil, nil -} - -// PrepareCommandArgs returns extra command-line arguments for the Codex process. -// This includes -c flags for MCP servers and sandbox configuration. -// Codex uses -c key=value flags to override config at runtime. -func (a *Adapter) PrepareCommandArgs() []string { - var args []string - - // Set sandbox_mode to workspace-write to enable file editing - args = append(args, "-c", "sandbox_mode=\"workspace-write\"") - - // Enable network access in sandbox - args = append(args, "-c", "sandbox_workspace_write.network_access=true") - - // Add MCP servers as -c flags - for _, server := range a.cfg.McpServers { - safeName := sanitizeCodexServerName(server.Name) - - if server.Type == "sse" || server.Type == "http" { - // HTTP/SSE transport - use url field - // Convert SSE URLs (/sse) to streamable HTTP URLs (/mcp) for Codex compatibility - url := server.URL - if url != "" { - url = convertSSEToStreamableHTTP(url) - args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.url=\"%s\"", safeName, url)) - } - } else if server.Command != "" { - // STDIO transport - use command field - args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.command=\"%s\"", safeName, server.Command)) - // Add args if present - if len(server.Args) > 0 { - // TOML array format: ["arg1", "arg2"] - quotedArgs := make([]string, len(server.Args)) - for i, arg := range server.Args { - quotedArgs[i] = fmt.Sprintf("\"%s\"", arg) - } - argsStr := "[" + strings.Join(quotedArgs, ", ") + "]" - args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.args=%s", safeName, argsStr)) - } - } - } - - a.logger.Info("PrepareCommandArgs", - zap.Int("mcp_server_count", len(a.cfg.McpServers)), - zap.Strings("extra_args", args)) - - return args -} - // Connect wires up the stdin/stdout pipes from the running agent subprocess. func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { a.mu.Lock() @@ -185,389 +133,32 @@ func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { return nil } -// sanitizeCodexServerName converts a server name to a valid TOML table name. -// Replaces spaces and special characters with underscores. -func sanitizeCodexServerName(name string) string { - var result strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { - result.WriteRune(r) - } else { - result.WriteRune('_') - } - } - sanitized := result.String() - if sanitized == "" { - return "server" - } - return sanitized -} - -// convertSSEToStreamableHTTP converts an SSE endpoint URL to a streamable HTTP endpoint URL. -// Codex doesn't support SSE transport - it uses streamable HTTP which requires POST requests. -// This converts URLs ending in /sse to /mcp for Kandev MCP server compatibility. -// Example: http://localhost:9090/sse -> http://localhost:9090/mcp -func convertSSEToStreamableHTTP(url string) string { - if strings.HasSuffix(url, "/sse") { - return strings.TrimSuffix(url, "/sse") + "/mcp" - } - return url -} - -// Initialize establishes the Codex connection with the agent subprocess. -func (a *Adapter) Initialize(ctx context.Context) error { - a.logger.Info("initializing Codex adapter", - zap.String("workdir", a.cfg.WorkDir)) - - // Create Codex client - a.client = codex.NewClient(a.stdin, a.stdout, a.logger) - a.client.SetNotificationHandler(a.handleNotification) - a.client.SetRequestHandler(a.handleRequest) - - // Start reading from stdout with the adapter's context - // The readLoop needs to stay alive for the entire lifecycle of the adapter, - // not just the initialize HTTP request. It will be cancelled when Close() is called. - a.client.Start(a.ctx) - - // Perform Codex initialize handshake - resp, err := a.client.Call(ctx, codex.MethodInitialize, &codex.InitializeParams{ - ClientInfo: &codex.ClientInfo{ - Name: "kandev-agentctl", - Title: "Kandev Agent Controller", - Version: "1.0.0", - }, - }) - if err != nil { - return fmt.Errorf("codex initialize handshake failed: %w", err) - } - if resp.Error != nil { - return fmt.Errorf("codex initialize error: %s", resp.Error.Message) - } - - // Parse initialize result - var initResult codex.InitializeResult - if resp.Result != nil { - if err := json.Unmarshal(resp.Result, &initResult); err != nil { - a.logger.Warn("failed to parse initialize result", zap.Error(err)) - } - } - - // Send initialized notification - if err := a.client.Notify(codex.MethodInitialized, nil); err != nil { - return fmt.Errorf("failed to send initialized notification: %w", err) - } - - // Store agent info - a.agentInfo = &AgentInfo{ - Name: a.agentID, - Version: initResult.UserAgent, - } - - a.logger.Info("Codex adapter initialized", - zap.String("user_agent", initResult.UserAgent)) - - return nil -} - -// GetAgentInfo returns information about the connected agent. -func (a *Adapter) GetAgentInfo() *AgentInfo { - return a.agentInfo -} - -// NewSession creates a new Codex thread (session). -// Note: The mcpServers parameter is ignored because Codex reads MCP configuration from -// ~/.codex/config.toml at startup time, not through the protocol. MCP servers are written -// to the config file by PrepareEnvironment() before the Codex process starts. -func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { - // Check client under lock, but don't hold lock during Call() to avoid deadlock - // with handleNotification which also needs the lock - a.mu.RLock() - client := a.client - a.mu.RUnlock() - - if client == nil { - return "", fmt.Errorf("adapter not initialized") - } - - // Determine approval policy - default to "untrusted" if not specified. - // "untrusted" forces Codex to request approval for all commands/writes. - // Other options: "on-failure", "on-request", "never" - approvalPolicy := a.cfg.ApprovalPolicy - if approvalPolicy == "" { - approvalPolicy = "untrusted" - } - - a.logger.Info("starting codex thread with approval policy", - zap.String("approval_policy", approvalPolicy), - zap.String("work_dir", a.cfg.WorkDir)) - - resp, err := client.Call(ctx, codex.MethodThreadStart, &codex.ThreadStartParams{ - Cwd: a.cfg.WorkDir, - ApprovalPolicy: approvalPolicy, // "untrusted", "on-failure", "on-request", "never" - SandboxPolicy: &codex.SandboxPolicy{ - Type: "workspace-write", // Sandbox to workspace only (kebab-case per Codex docs) - WritableRoots: []string{a.cfg.WorkDir}, // Allow writing to workspace - NetworkAccess: true, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to start thread: %w", err) - } - if resp.Error != nil { - return "", fmt.Errorf("thread start error: %s", resp.Error.Message) - } - - var result codex.ThreadStartResult - if err := json.Unmarshal(resp.Result, &result); err != nil { - return "", fmt.Errorf("failed to parse thread start result: %w", err) - } - +// Close releases resources held by the adapter. +func (a *Adapter) Close() error { a.mu.Lock() - a.threadID = result.Thread.ID - a.mu.Unlock() - - a.logger.Info("created new thread", zap.String("thread_id", a.threadID)) - - return a.threadID, nil -} - -// LoadSession resumes an existing Codex thread. -// It passes the same approval policy and sandbox settings as NewSession to ensure -// permission requirements are preserved across resume (see openai/codex#5322). -func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { - // Check client under lock, but don't hold lock during Call() to avoid deadlock - // with handleNotification which also needs the lock - a.mu.RLock() - client := a.client - a.mu.RUnlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - // Determine approval policy - same logic as NewSession - // "untrusted" forces Codex to request approval for all commands/writes. - approvalPolicy := a.cfg.ApprovalPolicy - if approvalPolicy == "" { - approvalPolicy = "untrusted" - } - - a.logger.Info("resuming codex thread with approval policy", - zap.String("thread_id", sessionID), - zap.String("approval_policy", approvalPolicy), - zap.String("work_dir", a.cfg.WorkDir)) - - resp, err := client.Call(ctx, codex.MethodThreadResume, &codex.ThreadResumeParams{ - ThreadID: sessionID, - Cwd: a.cfg.WorkDir, - ApprovalPolicy: approvalPolicy, - SandboxPolicy: &codex.SandboxPolicy{ - Type: "workspace-write", // kebab-case per Codex docs - WritableRoots: []string{a.cfg.WorkDir}, - NetworkAccess: true, - }, - }) - if err != nil { - return fmt.Errorf("failed to resume thread: %w", err) - } - if resp.Error != nil { - return fmt.Errorf("thread resume error: %s", resp.Error.Message) - } - - a.mu.Lock() - a.threadID = sessionID - a.mu.Unlock() - - a.logger.Info("resumed thread", zap.String("thread_id", a.threadID)) - - return nil -} - -// Prompt sends a prompt to the agent, starting a new turn. -// This method blocks until the turn completes (turn/completed notification received). -func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { - a.mu.Lock() - client := a.client - threadID := a.threadID - // Reset accumulators for new turn - a.messageBuffer = "" - a.reasoningBuffer = "" - a.currentReasoningItemID = "" - // Create channel to wait for turn completion - a.turnCompleteCh = make(chan turnCompleteResult, 1) - a.mu.Unlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - inputs := make([]codex.UserInput, 0, 1+len(attachments)) - if strings.TrimSpace(message) != "" { - inputs = append(inputs, codex.UserInput{Type: "text", Text: message}) - } - - if len(attachments) > 0 { - imagePaths, err := a.saveImageAttachments(a.cfg.WorkDir, attachments) - if err != nil { - a.logger.Warn("failed to save image attachments", zap.Error(err)) - } else { - for _, imagePath := range imagePaths { - inputs = append(inputs, codex.UserInput{Type: "localImage", Path: imagePath}) - } - } - } - - if len(inputs) == 0 { - return fmt.Errorf("prompt requires message text or attachments") - } - - a.logger.Info("sending prompt", - zap.String("thread_id", threadID), - zap.Int("inputs", len(inputs)), - zap.Int("image_attachments", len(attachments))) - - params := &codex.TurnStartParams{ - ThreadID: threadID, - Input: inputs, - } - - resp, err := client.Call(ctx, codex.MethodTurnStart, params) - if err != nil { - a.mu.Lock() - a.turnCompleteCh = nil - a.mu.Unlock() - return fmt.Errorf("failed to start turn: %w", err) - } - - if resp.Error != nil { - a.mu.Lock() - a.turnCompleteCh = nil - a.mu.Unlock() - return fmt.Errorf("turn start error: %s", resp.Error.Message) - } - - var result codex.TurnStartResult - if err := json.Unmarshal(resp.Result, &result); err != nil { - a.logger.Warn("failed to parse turn start result", zap.Error(err), zap.String("raw", string(resp.Result))) - } - - turnID := "" - if result.Turn != nil { - turnID = result.Turn.ID - } - - a.mu.Lock() - a.turnID = turnID - completeCh := a.turnCompleteCh - a.mu.Unlock() - - if result.Turn != nil { - a.logger.Info("started turn, waiting for completion", zap.String("turn_id", turnID), zap.String("status", result.Turn.Status)) - } else { - a.logger.Info("started turn, waiting for completion", zap.String("turn_id", turnID)) - } - - // Wait for turn completion or context cancellation - select { - case <-ctx.Done(): - a.mu.Lock() - a.turnCompleteCh = nil - a.mu.Unlock() - return ctx.Err() - case completeResult := <-completeCh: - a.mu.Lock() - a.turnCompleteCh = nil - a.mu.Unlock() - if !completeResult.success && completeResult.err != "" { - return fmt.Errorf("turn failed: %s", completeResult.err) - } - a.logger.Info("turn completed", zap.String("turn_id", turnID), zap.Bool("success", completeResult.success)) - - // Emit complete event via the stream. - // This normalizes Codex behavior to match other adapters. - // All adapters now emit complete events, eliminating the need for protocol-specific flags. - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: threadID, - OperationID: turnID, - }) + defer a.mu.Unlock() + if a.closed { return nil } -} + a.closed = true -// saveImageAttachments saves image attachments to temp files in the workspace. -func (a *Adapter) saveImageAttachments(workDir string, attachments []v1.MessageAttachment) ([]string, error) { - var imagePaths []string + a.logger.Info("closing Codex adapter") - if workDir == "" { - return nil, fmt.Errorf("workDir is required to save attachments") + // Cancel the context to stop the read loop goroutine + if a.cancel != nil { + a.cancel() } - tempDir := filepath.Join(workDir, ".kandev", "temp", "images") - if err := os.MkdirAll(tempDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create temp dir: %w", err) + // Stop the client + if a.client != nil { + a.client.Stop() } - for _, att := range attachments { - if att.Type != "image" { - continue - } + // Close update channel + close(a.updatesCh) - imageData, err := base64.StdEncoding.DecodeString(att.Data) - if err != nil { - a.logger.Warn("failed to decode image attachment", zap.Error(err)) - continue - } - - ext := ".png" - switch att.MimeType { - case "image/jpeg": - ext = ".jpg" - case "image/gif": - ext = ".gif" - case "image/webp": - ext = ".webp" - case "image/png": - ext = ".png" - } - - filename := fmt.Sprintf("image-%s%s", uuid.New().String()[:8], ext) - filePath := filepath.Join(tempDir, filename) - if err := os.WriteFile(filePath, imageData, 0644); err != nil { - a.logger.Warn("failed to write image file", zap.Error(err), zap.String("path", filePath)) - continue - } - - imagePaths = append(imagePaths, filePath) - a.logger.Info("saved image attachment", - zap.String("path", filePath), - zap.Int("size", len(imageData))) - } - - return imagePaths, nil -} - -// Cancel interrupts the current turn. -func (a *Adapter) Cancel(ctx context.Context) error { - a.mu.RLock() - client := a.client - threadID := a.threadID - turnID := a.turnID - a.mu.RUnlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - a.logger.Info("cancelling turn", zap.String("thread_id", threadID), zap.String("turn_id", turnID)) - - // Codex uses turn/interrupt to cancel - _, err := client.Call(ctx, codex.MethodTurnInterrupt, map[string]string{ - "threadId": threadID, - "turnId": turnID, - }) - return err + return nil } // Updates returns the channel for agent events. @@ -605,34 +196,6 @@ func (a *Adapter) SetStderrProvider(provider StderrProvider) { a.stderrProvider = provider } -// Close releases resources held by the adapter. -func (a *Adapter) Close() error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.closed { - return nil - } - a.closed = true - - a.logger.Info("closing Codex adapter") - - // Cancel the context to stop the read loop goroutine - if a.cancel != nil { - a.cancel() - } - - // Stop the client - if a.client != nil { - a.client.Stop() - } - - // Close update channel - close(a.updatesCh) - - return nil -} - // RequiresProcessKill returns false because Codex agents exit when stdin is closed. func (a *Adapter) RequiresProcessKill() bool { return false @@ -659,582 +222,35 @@ func (a *Adapter) handleNotification(method string, params json.RawMessage) { a.mu.RUnlock() switch method { - // Standard notifications case codex.NotifyItemAgentMessageDelta: - var p codex.AgentMessageDeltaParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse agent message delta", zap.Error(err)) - return - } - a.mu.Lock() - a.messageBuffer += p.Delta - a.mu.Unlock() - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: threadID, - OperationID: turnID, - Text: p.Delta, - }) - + a.handleAgentMessageDelta(params, threadID, turnID) case codex.NotifyItemReasoningTextDelta: - var p codex.ReasoningDeltaParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse reasoning text delta", zap.Error(err)) - return - } - a.mu.Lock() - // Add separator when switching to a new reasoning item - if p.ItemID != a.currentReasoningItemID && a.reasoningBuffer != "" { - a.reasoningBuffer += "\n\n" - } - a.currentReasoningItemID = p.ItemID - a.reasoningBuffer += p.Delta - a.mu.Unlock() - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeReasoning, - SessionID: threadID, - OperationID: turnID, - ReasoningText: p.Delta, - }) - + a.handleReasoningDelta(params, threadID, turnID, "reasoning text delta") case codex.NotifyItemReasoningSummaryDelta: - // Codex sends reasoning via summaryTextDelta - normalize to ReasoningText - // so the lifecycle manager doesn't need to know about protocol differences - var p codex.ReasoningDeltaParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse reasoning summary delta", zap.Error(err)) - return - } - a.mu.Lock() - // Add separator when switching to a new reasoning item - if p.ItemID != a.currentReasoningItemID && a.reasoningBuffer != "" { - a.reasoningBuffer += "\n\n" - } - a.currentReasoningItemID = p.ItemID - a.reasoningBuffer += p.Delta - a.mu.Unlock() - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeReasoning, - SessionID: threadID, - OperationID: turnID, - ReasoningText: p.Delta, - }) - + a.handleReasoningDelta(params, threadID, turnID, "reasoning summary delta") case codex.NotifyTurnCompleted: - var p codex.TurnCompletedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse turn completed", zap.Error(err)) - return - } - - // Signal turn completion to the waiting Prompt() call - a.mu.RLock() - completeCh := a.turnCompleteCh - a.mu.RUnlock() - - if completeCh != nil { - select { - case completeCh <- turnCompleteResult{success: p.Success, err: p.Error}: - a.logger.Debug("signaled turn completion", zap.String("turn_id", p.TurnID), zap.Bool("success", p.Success)) - default: - a.logger.Warn("turn complete channel full, dropping signal") - } - } - - // Send error event if the turn failed WITH an explicit error message. - // Note: We don't send error events here based on stderr alone, because - // NotifyError will handle error notifications (prevents duplicate messages). - if !p.Success { - a.logger.Debug("turn completed with failure", - zap.String("thread_id", threadID), - zap.String("turn_id", p.TurnID), - zap.Bool("success", p.Success), - zap.String("error", p.Error)) - - // Only send error event if there's an explicit error message - // (NotifyError handles the case when error details come separately) - if p.Error != "" { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: threadID, - OperationID: p.TurnID, - Error: p.Error, - }) - } - } - + a.handleTurnCompleted(params, threadID) case codex.NotifyTurnDiffUpdated: - var p codex.TurnDiffUpdatedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse turn diff updated", zap.Error(err)) - return - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: threadID, - OperationID: p.TurnID, - Diff: p.Diff, - }) - + a.handleTurnDiffUpdated(params, threadID) case codex.NotifyTurnPlanUpdated: - var p codex.TurnPlanUpdatedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse turn plan updated", zap.Error(err)) - return - } - entries := make([]PlanEntry, len(p.Plan)) - for i, e := range p.Plan { - entries[i] = PlanEntry{ - Description: e.Description, - Status: e.Status, - } - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypePlan, - SessionID: threadID, - OperationID: p.TurnID, - PlanEntries: entries, - }) - + a.handleTurnPlanUpdated(params, threadID) case codex.NotifyError: - var p codex.ErrorParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse error notification", zap.Error(err)) - return - } - - // Get recent stderr for error context - var stderrLines []string - a.mu.RLock() - if a.stderrProvider != nil { - stderrLines = a.stderrProvider.GetRecentStderr() - } - a.mu.RUnlock() - - // Try to parse stderr into structured error info - // This handles Codex-specific error formats (e.g., rate limits) - parsedError := ParseCodexStderrLines(stderrLines) - - var parsedMsg string - if parsedError != nil { - parsedMsg = parsedError.Message - } - - a.logger.Debug("received error notification from agent", - zap.String("thread_id", threadID), - zap.Int("code", p.Code), - zap.String("message", p.Message), - zap.String("parsed_message", parsedMsg), - zap.Any("data", p.Data), - zap.Int("stderr_lines", len(stderrLines))) - - // Build error data with all available context - errorData := map[string]any{ - "code": p.Code, - "data": p.Data, - "stderr": stderrLines, - } - - // Include parsed error details if available - if parsedError != nil { - parsed := map[string]any{ - "http_error": parsedError.HTTPError, - } - // Include the full raw JSON (captures all fields from any error type) - if parsedError.RawJSON != nil { - parsed["error_json"] = parsedError.RawJSON - } - errorData["parsed"] = parsed - } - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: threadID, - Error: p.Message, - Text: parsedMsg, // User-friendly parsed message - Data: errorData, - }) - + a.handleErrorNotification(params, threadID) case codex.NotifyItemStarted: - var p codex.ItemStartedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse item started", zap.Error(err)) - return - } - if p.Item == nil { - return - } - // Map Codex item types to tool call updates - // Item types: "userMessage", "agentMessage", "commandExecution", "fileChange", "reasoning", "mcpToolCall" - switch p.Item.Type { - case CodexItemCommandExecution: - args := map[string]any{ - "command": p.Item.Command, - "cwd": p.Item.Cwd, - } - normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemCommandExecution, args) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.Item.ID, - ToolName: CodexItemCommandExecution, - ToolTitle: p.Item.Command, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) - case CodexItemFileChange: - // Build title from file paths - var title string - if len(p.Item.Changes) > 0 { - title = p.Item.Changes[0].Path - if len(p.Item.Changes) > 1 { - title += fmt.Sprintf(" (+%d more)", len(p.Item.Changes)-1) - } - } - // Build args with changes info - changesArgs := make([]any, 0, len(p.Item.Changes)) - for _, c := range p.Item.Changes { - changesArgs = append(changesArgs, map[string]any{ - "path": c.Path, - "diff": c.Diff, - }) - } - args := map[string]any{ - "changes": changesArgs, - } - normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemFileChange, args) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.Item.ID, - ToolName: CodexItemFileChange, - ToolTitle: title, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) - case CodexItemMcpToolCall: - // Handle MCP tool calls (e.g., create_task_plan_kandev) - // Build title from server/tool - title := p.Item.Tool - if p.Item.Server != "" { - title = p.Item.Server + "/" + p.Item.Tool - } - // Parse arguments as map for normalization - var argsMap map[string]any - if len(p.Item.Arguments) > 0 { - _ = json.Unmarshal(p.Item.Arguments, &argsMap) - } - args := map[string]any{ - "server": p.Item.Server, - "tool": p.Item.Tool, - "arguments": argsMap, - } - normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemMcpToolCall, args) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.Item.ID, - ToolName: p.Item.Tool, // Use the actual MCP tool name for frontend display - ToolTitle: title, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) - } - + a.handleItemStarted(params, threadID, turnID) case codex.NotifyItemCompleted: - var p codex.ItemCompletedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse item completed", zap.Error(err)) - return - } - if p.Item == nil { - return - } - // Only send updates for tool-like items - if p.Item.Type == CodexItemCommandExecution || p.Item.Type == CodexItemFileChange || p.Item.Type == CodexItemMcpToolCall { - status := "complete" - if p.Item.Status == "failed" { - status = "error" - } - update := AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.Item.ID, - ToolStatus: status, - } - - // Include normalized payload for fallback message creation - // This ensures the correct message type is used if the message doesn't exist yet - switch p.Item.Type { - case CodexItemCommandExecution: - args := map[string]any{ - "command": p.Item.Command, - "cwd": p.Item.Cwd, - } - update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemCommandExecution, args) - case CodexItemFileChange: - changesArgs := make([]any, 0, len(p.Item.Changes)) - for _, c := range p.Item.Changes { - changesArgs = append(changesArgs, map[string]any{ - "path": c.Path, - "diff": c.Diff, - }) - } - args := map[string]any{ - "changes": changesArgs, - } - update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemFileChange, args) - case CodexItemMcpToolCall: - // Parse arguments and result for normalization - var argsMap map[string]any - if len(p.Item.Arguments) > 0 { - _ = json.Unmarshal(p.Item.Arguments, &argsMap) - } - var resultMap any - if len(p.Item.Result) > 0 { - _ = json.Unmarshal(p.Item.Result, &resultMap) - } - args := map[string]any{ - "server": p.Item.Server, - "tool": p.Item.Tool, - "arguments": argsMap, - "result": resultMap, - } - if p.Item.ToolError != "" { - args["error"] = p.Item.ToolError - } - update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemMcpToolCall, args) - update.ToolName = p.Item.Tool // Use the actual MCP tool name - } - - // Include diff for file changes - if p.Item.Type == CodexItemFileChange && len(p.Item.Changes) > 0 { - var diffs []string - for _, c := range p.Item.Changes { - if c.Diff != "" { - diffs = append(diffs, c.Diff) - } - } - if len(diffs) > 0 { - update.Diff = strings.Join(diffs, "\n") - } - } - a.sendUpdate(update) - } - + a.handleItemCompleted(params, threadID, turnID) case codex.NotifyItemCmdExecOutputDelta: - var p codex.CommandOutputDeltaParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse command output delta", zap.Error(err)) - return - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.ItemID, - }) - + a.handleCmdExecOutputDelta(params, threadID, turnID) case codex.NotifyThreadTokenUsageUpdated: - var p codex.ThreadTokenUsageUpdatedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse thread token usage updated notification", zap.Error(err)) - return - } - // Extract context window information from the token usage update - if p.TokenUsage != nil && p.TokenUsage.ModelContextWindow > 0 { - contextWindowSize := p.TokenUsage.ModelContextWindow - contextWindowUsed := int64(p.TokenUsage.Last.TotalTokens) - - remaining := contextWindowSize - contextWindowUsed - if remaining < 0 { - remaining = 0 - } - efficiency := float64(contextWindowUsed) / float64(contextWindowSize) * 100 - - a.logger.Debug("emitting context window event", - zap.Int64("size", contextWindowSize), - zap.Int64("used", contextWindowUsed), - zap.Int64("remaining", remaining), - zap.Float64("efficiency", efficiency)) - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeContextWindow, - SessionID: threadID, - OperationID: turnID, - ContextWindowSize: contextWindowSize, - ContextWindowUsed: contextWindowUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: efficiency, - }) - } - + a.handleTokenUsageUpdated(params, threadID, turnID) case codex.NotifyTokenCount: // Legacy token_count notification - ignore as we now use thread/tokenUsage/updated a.logger.Debug("ignoring legacy token_count notification") - case codex.NotifyContextCompacted: - var p codex.ContextCompactedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse context compacted notification", zap.Error(err)) - return - } - a.logger.Info("context compacted", - zap.String("thread_id", p.ThreadID), - zap.String("turn_id", p.TurnID)) - // We could emit an event here if we want to notify the frontend about compaction - + a.handleContextCompacted(params) default: // Log unhandled notifications at debug level a.logger.Debug("unhandled notification", zap.String("method", method)) } } - -// handleRequest processes Codex requests (approval requests) and calls permissionHandler. -func (a *Adapter) handleRequest(id any, method string, params json.RawMessage) { - a.logger.Debug("codex: received request", - zap.Any("id", id), - zap.String("method", method)) - - a.mu.RLock() - handler := a.permissionHandler - a.mu.RUnlock() - - switch method { - case codex.NotifyItemCmdExecRequestApproval: - var p codex.CommandApprovalParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse command approval request", zap.Error(err)) - if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.InvalidParams, Message: "invalid params"}); err != nil { - a.logger.Warn("failed to send invalid params response", zap.Error(err)) - } - return - } - a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeCommand, p.Command, map[string]any{ - "command": p.Command, - "cwd": p.Cwd, - "reasoning": p.Reasoning, - }, p.Options) - - case codex.NotifyItemFileChangeRequestApproval: - var p codex.FileChangeApprovalParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse file change approval request", zap.Error(err)) - if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.InvalidParams, Message: "invalid params"}); err != nil { - a.logger.Warn("failed to send invalid params response", zap.Error(err)) - } - return - } - a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeFileWrite, p.Path, map[string]any{ - "path": p.Path, - "diff": p.Diff, - "reasoning": p.Reasoning, - }, p.Options) - - default: - a.logger.Warn("unhandled request", zap.String("method", method)) - if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.MethodNotFound, Message: "method not found"}); err != nil { - a.logger.Warn("failed to send method not found response", zap.Error(err)) - } - } -} - -// handleApprovalRequest handles permission request logic for both command and file change approvals. -func (a *Adapter) handleApprovalRequest( - id any, - handler PermissionHandler, - threadID string, - itemID string, - actionType string, - title string, - details map[string]any, - optionStrings []string, -) { - // Build permission options from Codex options - options := make([]PermissionOption, len(optionStrings)) - for i, opt := range optionStrings { - kind := "allow_once" - switch opt { - case "approveAlways": - kind = "allow_always" - case "reject": - kind = "reject_once" - } - options[i] = PermissionOption{ - OptionID: opt, - Name: opt, - Kind: kind, - } - } - - // If no options provided, use defaults - if len(options) == 0 { - options = []PermissionOption{ - {OptionID: "approve", Name: "Approve", Kind: "allow_once"}, - {OptionID: "reject", Name: "Reject", Kind: "reject_once"}, - } - } - - req := &PermissionRequest{ - SessionID: threadID, - ToolCallID: itemID, - Title: title, - Options: options, - ActionType: actionType, - ActionDetails: details, - } - - if handler == nil { - // Auto-approve if no handler - if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ - Decision: "accept", - }, nil); err != nil { - a.logger.Warn("failed to send approval response", zap.Error(err)) - } - return - } - - ctx := context.Background() - resp, err := handler(ctx, req) - if err != nil { - a.logger.Error("permission handler error", zap.Error(err)) - if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ - Decision: "decline", - }, nil); err != nil { - a.logger.Warn("failed to send decline response", zap.Error(err)) - } - return - } - - // Map frontend option to Codex decision - // Codex accepts: "accept", "acceptForSession", "decline", "cancel" - decision := "accept" - if resp.Cancelled { - decision = "cancel" - } else { - switch resp.OptionID { - case "approve", "allow", "accept": - decision = "accept" - case "approveAlways", "allowAlways", "acceptForSession": - decision = "acceptForSession" - case "reject", "deny", "decline": - decision = "decline" - case "cancel": - decision = "cancel" - default: - if resp.OptionID != "" { - decision = resp.OptionID - } - } - } - - if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ - Decision: decision, - }, nil); err != nil { - a.logger.Warn("failed to send approval response", zap.Error(err)) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_init.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_init.go new file mode 100644 index 0000000000..cc5a05bbbd --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_init.go @@ -0,0 +1,154 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/kandev/kandev/pkg/codex" + "go.uber.org/zap" +) + +// PrepareEnvironment is a no-op for Codex. MCP servers and sandbox settings +// are now passed via command-line -c flags through PrepareCommandArgs(). +func (a *Adapter) PrepareEnvironment() (map[string]string, error) { + a.logger.Info("PrepareEnvironment called (no-op for Codex)") + return nil, nil +} + +// PrepareCommandArgs returns extra command-line arguments for the Codex process. +// This includes -c flags for MCP servers and sandbox configuration. +// Codex uses -c key=value flags to override config at runtime. +func (a *Adapter) PrepareCommandArgs() []string { + var args []string + + // Set sandbox_mode to workspace-write to enable file editing + args = append(args, "-c", "sandbox_mode=\"workspace-write\"") + + // Enable network access in sandbox + args = append(args, "-c", "sandbox_workspace_write.network_access=true") + + // Add MCP servers as -c flags + for _, server := range a.cfg.McpServers { + safeName := sanitizeCodexServerName(server.Name) + + if server.Type == "sse" || server.Type == "http" { + // HTTP/SSE transport - use url field + // Convert SSE URLs (/sse) to streamable HTTP URLs (/mcp) for Codex compatibility + url := server.URL + if url != "" { + url = convertSSEToStreamableHTTP(url) + args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.url=\"%s\"", safeName, url)) + } + } else if server.Command != "" { + // STDIO transport - use command field + args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.command=\"%s\"", safeName, server.Command)) + // Add args if present + if len(server.Args) > 0 { + // TOML array format: ["arg1", "arg2"] + quotedArgs := make([]string, len(server.Args)) + for i, arg := range server.Args { + quotedArgs[i] = fmt.Sprintf("\"%s\"", arg) + } + argsStr := "[" + strings.Join(quotedArgs, ", ") + "]" + args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.args=%s", safeName, argsStr)) + } + } + } + + a.logger.Info("PrepareCommandArgs", + zap.Int("mcp_server_count", len(a.cfg.McpServers)), + zap.Strings("extra_args", args)) + + return args +} + +// sanitizeCodexServerName converts a server name to a valid TOML table name. +// Replaces spaces and special characters with underscores. +func sanitizeCodexServerName(name string) string { + var result strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + result.WriteRune(r) + } else { + result.WriteRune('_') + } + } + sanitized := result.String() + if sanitized == "" { + return "server" + } + return sanitized +} + +// convertSSEToStreamableHTTP converts an SSE endpoint URL to a streamable HTTP endpoint URL. +// Codex doesn't support SSE transport - it uses streamable HTTP which requires POST requests. +// This converts URLs ending in /sse to /mcp for Kandev MCP server compatibility. +// Example: http://localhost:9090/sse -> http://localhost:9090/mcp +func convertSSEToStreamableHTTP(url string) string { + if strings.HasSuffix(url, "/sse") { + return strings.TrimSuffix(url, "/sse") + "/mcp" + } + return url +} + +// Initialize establishes the Codex connection with the agent subprocess. +func (a *Adapter) Initialize(ctx context.Context) error { + a.logger.Info("initializing Codex adapter", + zap.String("workdir", a.cfg.WorkDir)) + + // Create Codex client + a.client = codex.NewClient(a.stdin, a.stdout, a.logger) + a.client.SetNotificationHandler(a.handleNotification) + a.client.SetRequestHandler(a.handleRequest) + + // Start reading from stdout with the adapter's context + // The readLoop needs to stay alive for the entire lifecycle of the adapter, + // not just the initialize HTTP request. It will be cancelled when Close() is called. + a.client.Start(a.ctx) + + // Perform Codex initialize handshake + resp, err := a.client.Call(ctx, codex.MethodInitialize, &codex.InitializeParams{ + ClientInfo: &codex.ClientInfo{ + Name: "kandev-agentctl", + Title: "Kandev Agent Controller", + Version: "1.0.0", + }, + }) + if err != nil { + return fmt.Errorf("codex initialize handshake failed: %w", err) + } + if resp.Error != nil { + return fmt.Errorf("codex initialize error: %s", resp.Error.Message) + } + + // Parse initialize result + var initResult codex.InitializeResult + if resp.Result != nil { + if err := json.Unmarshal(resp.Result, &initResult); err != nil { + a.logger.Warn("failed to parse initialize result", zap.Error(err)) + } + } + + // Send initialized notification + if err := a.client.Notify(codex.MethodInitialized, nil); err != nil { + return fmt.Errorf("failed to send initialized notification: %w", err) + } + + // Store agent info + a.agentInfo = &AgentInfo{ + Name: a.agentID, + Version: initResult.UserAgent, + } + + a.logger.Info("Codex adapter initialized", + zap.String("user_agent", initResult.UserAgent)) + + return nil +} + +// GetAgentInfo returns information about the connected agent. +func (a *Adapter) GetAgentInfo() *AgentInfo { + return a.agentInfo +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_items.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_items.go new file mode 100644 index 0000000000..75b2a84155 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_items.go @@ -0,0 +1,176 @@ +package codex + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/pkg/codex" + "go.uber.org/zap" +) + +// handleItemStarted handles item/started notifications. +func (a *Adapter) handleItemStarted(params json.RawMessage, threadID, turnID string) { + var p codex.ItemStartedParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse item started", zap.Error(err)) + return + } + if p.Item == nil { + return + } + // Map Codex item types to tool call updates + // Item types: "userMessage", "agentMessage", "commandExecution", "fileChange", "reasoning", "mcpToolCall" + switch p.Item.Type { + case CodexItemCommandExecution: + args := map[string]any{"command": p.Item.Command, "cwd": p.Item.Cwd} + normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemCommandExecution, args) + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolCall, + SessionID: threadID, + OperationID: turnID, + ToolCallID: p.Item.ID, + ToolName: CodexItemCommandExecution, + ToolTitle: p.Item.Command, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, + }) + case CodexItemFileChange: + a.sendFileChangeStarted(p.Item, threadID, turnID) + case CodexItemMcpToolCall: + a.sendMcpToolCallStarted(p.Item, threadID, turnID) + } +} + +// sendFileChangeStarted emits a tool_call event for a file change item. +func (a *Adapter) sendFileChangeStarted(item *codex.Item, threadID, turnID string) { + var title string + if len(item.Changes) > 0 { + title = item.Changes[0].Path + if len(item.Changes) > 1 { + title += fmt.Sprintf(" (+%d more)", len(item.Changes)-1) + } + } + changesArgs := make([]any, 0, len(item.Changes)) + for _, c := range item.Changes { + changesArgs = append(changesArgs, map[string]any{"path": c.Path, "diff": c.Diff}) + } + args := map[string]any{"changes": changesArgs} + normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemFileChange, args) + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolCall, + SessionID: threadID, + OperationID: turnID, + ToolCallID: item.ID, + ToolName: CodexItemFileChange, + ToolTitle: title, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, + }) +} + +// sendMcpToolCallStarted emits a tool_call event for an MCP tool call item. +func (a *Adapter) sendMcpToolCallStarted(item *codex.Item, threadID, turnID string) { + title := item.Tool + if item.Server != "" { + title = item.Server + "/" + item.Tool + } + var argsMap map[string]any + if len(item.Arguments) > 0 { + _ = json.Unmarshal(item.Arguments, &argsMap) + } + args := map[string]any{"server": item.Server, "tool": item.Tool, "arguments": argsMap} + normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemMcpToolCall, args) + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolCall, + SessionID: threadID, + OperationID: turnID, + ToolCallID: item.ID, + ToolName: item.Tool, // Use the actual MCP tool name for frontend display + ToolTitle: title, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, + }) +} + +// handleItemCompleted handles item/completed notifications. +func (a *Adapter) handleItemCompleted(params json.RawMessage, threadID, turnID string) { + var p codex.ItemCompletedParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse item completed", zap.Error(err)) + return + } + if p.Item == nil { + return + } + // Only send updates for tool-like items + if p.Item.Type != CodexItemCommandExecution && p.Item.Type != CodexItemFileChange && p.Item.Type != CodexItemMcpToolCall { + return + } + + status := "complete" + if p.Item.Status == "failed" { + status = "error" + } + update := AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: threadID, + OperationID: turnID, + ToolCallID: p.Item.ID, + ToolStatus: status, + } + + // Include normalized payload for fallback message creation + // This ensures the correct message type is used if the message doesn't exist yet + a.attachCompletedItemPayload(&update, p.Item) + a.sendUpdate(update) +} + +// attachCompletedItemPayload sets the NormalizedPayload and Diff fields on a completed item event. +func (a *Adapter) attachCompletedItemPayload(update *AgentEvent, item *codex.Item) { + switch item.Type { + case CodexItemCommandExecution: + args := map[string]any{"command": item.Command, "cwd": item.Cwd} + update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemCommandExecution, args) + case CodexItemFileChange: + changesArgs := make([]any, 0, len(item.Changes)) + for _, c := range item.Changes { + changesArgs = append(changesArgs, map[string]any{"path": c.Path, "diff": c.Diff}) + } + args := map[string]any{"changes": changesArgs} + update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemFileChange, args) + // Include diff for file changes + if len(item.Changes) > 0 { + var diffs []string + for _, c := range item.Changes { + if c.Diff != "" { + diffs = append(diffs, c.Diff) + } + } + if len(diffs) > 0 { + update.Diff = strings.Join(diffs, "\n") + } + } + case CodexItemMcpToolCall: + var argsMap map[string]any + if len(item.Arguments) > 0 { + _ = json.Unmarshal(item.Arguments, &argsMap) + } + var resultMap any + if len(item.Result) > 0 { + _ = json.Unmarshal(item.Result, &resultMap) + } + args := map[string]any{ + "server": item.Server, + "tool": item.Tool, + "arguments": argsMap, + "result": resultMap, + } + if item.ToolError != "" { + args["error"] = item.ToolError + } + update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemMcpToolCall, args) + update.ToolName = item.Tool // Use the actual MCP tool name + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_notifications.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_notifications.go new file mode 100644 index 0000000000..1c9577720e --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_notifications.go @@ -0,0 +1,258 @@ +package codex + +import ( + "encoding/json" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/pkg/codex" + "go.uber.org/zap" +) + +// handleAgentMessageDelta handles item/agentMessage/delta notifications. +func (a *Adapter) handleAgentMessageDelta(params json.RawMessage, threadID, turnID string) { + var p codex.AgentMessageDeltaParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse agent message delta", zap.Error(err)) + return + } + a.mu.Lock() + a.messageBuffer += p.Delta + a.mu.Unlock() + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeMessageChunk, + SessionID: threadID, + OperationID: turnID, + Text: p.Delta, + }) +} + +// handleReasoningDelta handles reasoning text and summary delta notifications. +func (a *Adapter) handleReasoningDelta(params json.RawMessage, threadID, turnID, logLabel string) { + var p codex.ReasoningDeltaParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse "+logLabel, zap.Error(err)) + return + } + a.mu.Lock() + // Add separator when switching to a new reasoning item + if p.ItemID != a.currentReasoningItemID && a.reasoningBuffer != "" { + a.reasoningBuffer += "\n\n" + } + a.currentReasoningItemID = p.ItemID + a.reasoningBuffer += p.Delta + a.mu.Unlock() + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeReasoning, + SessionID: threadID, + OperationID: turnID, + ReasoningText: p.Delta, + }) +} + +// handleTurnCompleted handles turn/completed notifications. +func (a *Adapter) handleTurnCompleted(params json.RawMessage, threadID string) { + var p codex.TurnCompletedParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse turn completed", zap.Error(err)) + return + } + + // Signal turn completion to the waiting Prompt() call + a.mu.RLock() + completeCh := a.turnCompleteCh + a.mu.RUnlock() + + if completeCh != nil { + select { + case completeCh <- turnCompleteResult{success: p.Success, err: p.Error}: + a.logger.Debug("signaled turn completion", zap.String("turn_id", p.TurnID), zap.Bool("success", p.Success)) + default: + a.logger.Warn("turn complete channel full, dropping signal") + } + } + + // Send error event if the turn failed WITH an explicit error message. + // Note: We don't send error events here based on stderr alone, because + // NotifyError will handle error notifications (prevents duplicate messages). + if !p.Success { + a.logger.Debug("turn completed with failure", + zap.String("thread_id", threadID), + zap.String("turn_id", p.TurnID), + zap.Bool("success", p.Success), + zap.String("error", p.Error)) + + // Only send error event if there's an explicit error message + // (NotifyError handles the case when error details come separately) + if p.Error != "" { + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeError, + SessionID: threadID, + OperationID: p.TurnID, + Error: p.Error, + }) + } + } +} + +// handleTurnDiffUpdated handles turn/diffUpdated notifications. +func (a *Adapter) handleTurnDiffUpdated(params json.RawMessage, threadID string) { + var p codex.TurnDiffUpdatedParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse turn diff updated", zap.Error(err)) + return + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeMessageChunk, + SessionID: threadID, + OperationID: p.TurnID, + Diff: p.Diff, + }) +} + +// handleTurnPlanUpdated handles turn/planUpdated notifications. +func (a *Adapter) handleTurnPlanUpdated(params json.RawMessage, threadID string) { + var p codex.TurnPlanUpdatedParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse turn plan updated", zap.Error(err)) + return + } + entries := make([]PlanEntry, len(p.Plan)) + for i, e := range p.Plan { + entries[i] = PlanEntry{ + Description: e.Description, + Status: e.Status, + } + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypePlan, + SessionID: threadID, + OperationID: p.TurnID, + PlanEntries: entries, + }) +} + +// handleErrorNotification handles error notifications from the agent. +func (a *Adapter) handleErrorNotification(params json.RawMessage, threadID string) { + var p codex.ErrorParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse error notification", zap.Error(err)) + return + } + + // Get recent stderr for error context + var stderrLines []string + a.mu.RLock() + if a.stderrProvider != nil { + stderrLines = a.stderrProvider.GetRecentStderr() + } + a.mu.RUnlock() + + // Try to parse stderr into structured error info + // This handles Codex-specific error formats (e.g., rate limits) + parsedError := ParseCodexStderrLines(stderrLines) + + var parsedMsg string + if parsedError != nil { + parsedMsg = parsedError.Message + } + + a.logger.Debug("received error notification from agent", + zap.String("thread_id", threadID), + zap.Int("code", p.Code), + zap.String("message", p.Message), + zap.String("parsed_message", parsedMsg), + zap.Any("data", p.Data), + zap.Int("stderr_lines", len(stderrLines))) + + // Build error data with all available context + errorData := map[string]any{ + "code": p.Code, + "data": p.Data, + "stderr": stderrLines, + } + + // Include parsed error details if available + if parsedError != nil { + parsed := map[string]any{ + "http_error": parsedError.HTTPError, + } + // Include the full raw JSON (captures all fields from any error type) + if parsedError.RawJSON != nil { + parsed["error_json"] = parsedError.RawJSON + } + errorData["parsed"] = parsed + } + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeError, + SessionID: threadID, + Error: p.Message, + Text: parsedMsg, // User-friendly parsed message + Data: errorData, + }) +} + +// handleCmdExecOutputDelta handles item/cmdExec/outputDelta notifications. +func (a *Adapter) handleCmdExecOutputDelta(params json.RawMessage, threadID, turnID string) { + var p codex.CommandOutputDeltaParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse command output delta", zap.Error(err)) + return + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: threadID, + OperationID: turnID, + ToolCallID: p.ItemID, + }) +} + +// handleTokenUsageUpdated handles thread/tokenUsage/updated notifications. +func (a *Adapter) handleTokenUsageUpdated(params json.RawMessage, threadID, turnID string) { + var p codex.ThreadTokenUsageUpdatedParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse thread token usage updated notification", zap.Error(err)) + return + } + // Extract context window information from the token usage update + if p.TokenUsage == nil || p.TokenUsage.ModelContextWindow <= 0 { + return + } + contextWindowSize := p.TokenUsage.ModelContextWindow + contextWindowUsed := int64(p.TokenUsage.Last.TotalTokens) + + remaining := contextWindowSize - contextWindowUsed + if remaining < 0 { + remaining = 0 + } + efficiency := float64(contextWindowUsed) / float64(contextWindowSize) * 100 + + a.logger.Debug("emitting context window event", + zap.Int64("size", contextWindowSize), + zap.Int64("used", contextWindowUsed), + zap.Int64("remaining", remaining), + zap.Float64("efficiency", efficiency)) + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeContextWindow, + SessionID: threadID, + OperationID: turnID, + ContextWindowSize: contextWindowSize, + ContextWindowUsed: contextWindowUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: efficiency, + }) +} + +// handleContextCompacted handles context/compacted notifications. +func (a *Adapter) handleContextCompacted(params json.RawMessage) { + var p codex.ContextCompactedParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse context compacted notification", zap.Error(err)) + return + } + a.logger.Info("context compacted", + zap.String("thread_id", p.ThreadID), + zap.String("turn_id", p.TurnID)) + // We could emit an event here if we want to notify the frontend about compaction +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_permissions.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_permissions.go new file mode 100644 index 0000000000..673df8f636 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_permissions.go @@ -0,0 +1,155 @@ +package codex + +import ( + "context" + "encoding/json" + + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/pkg/codex" + "go.uber.org/zap" +) + +// handleRequest processes Codex requests (approval requests) and calls permissionHandler. +func (a *Adapter) handleRequest(id any, method string, params json.RawMessage) { + a.logger.Debug("codex: received request", + zap.Any("id", id), + zap.String("method", method)) + + a.mu.RLock() + handler := a.permissionHandler + a.mu.RUnlock() + + switch method { + case codex.NotifyItemCmdExecRequestApproval: + var p codex.CommandApprovalParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse command approval request", zap.Error(err)) + if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.InvalidParams, Message: "invalid params"}); err != nil { + a.logger.Warn("failed to send invalid params response", zap.Error(err)) + } + return + } + a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeCommand, p.Command, map[string]any{ + "command": p.Command, + "cwd": p.Cwd, + "reasoning": p.Reasoning, + }, p.Options) + + case codex.NotifyItemFileChangeRequestApproval: + var p codex.FileChangeApprovalParams + if err := json.Unmarshal(params, &p); err != nil { + a.logger.Warn("failed to parse file change approval request", zap.Error(err)) + if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.InvalidParams, Message: "invalid params"}); err != nil { + a.logger.Warn("failed to send invalid params response", zap.Error(err)) + } + return + } + a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeFileWrite, p.Path, map[string]any{ + "path": p.Path, + "diff": p.Diff, + "reasoning": p.Reasoning, + }, p.Options) + + default: + a.logger.Warn("unhandled request", zap.String("method", method)) + if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.MethodNotFound, Message: "method not found"}); err != nil { + a.logger.Warn("failed to send method not found response", zap.Error(err)) + } + } +} + +// handleApprovalRequest handles permission request logic for both command and file change approvals. +func (a *Adapter) handleApprovalRequest( + id any, + handler PermissionHandler, + threadID string, + itemID string, + actionType string, + title string, + details map[string]any, + optionStrings []string, +) { + req := &PermissionRequest{ + SessionID: threadID, + ToolCallID: itemID, + Title: title, + Options: buildPermissionOptions(optionStrings), + ActionType: actionType, + ActionDetails: details, + } + + if handler == nil { + // Auto-approve if no handler + if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ + Decision: decisionAccept, + }, nil); err != nil { + a.logger.Warn("failed to send approval response", zap.Error(err)) + } + return + } + + ctx := context.Background() + resp, err := handler(ctx, req) + if err != nil { + a.logger.Error("permission handler error", zap.Error(err)) + if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ + Decision: decisionDecline, + }, nil); err != nil { + a.logger.Warn("failed to send decline response", zap.Error(err)) + } + return + } + + decision := mapResponseToDecision(resp) + if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ + Decision: decision, + }, nil); err != nil { + a.logger.Warn("failed to send approval response", zap.Error(err)) + } +} + +// buildPermissionOptions converts Codex option strings to PermissionOption slice. +// Falls back to default approve/reject options when no options are provided. +func buildPermissionOptions(optionStrings []string) []PermissionOption { + if len(optionStrings) == 0 { + return []PermissionOption{ + {OptionID: "approve", Name: "Approve", Kind: "allow_once"}, + {OptionID: "reject", Name: "Reject", Kind: "reject_once"}, + } + } + options := make([]PermissionOption, len(optionStrings)) + for i, opt := range optionStrings { + kind := "allow_once" + switch opt { + case "approveAlways": + kind = "allow_always" + case "reject": + kind = "reject_once" + } + options[i] = PermissionOption{OptionID: opt, Name: opt, Kind: kind} + } + return options +} + +// mapResponseToDecision maps a PermissionResponse to a Codex decision string. +// Codex accepts: "accept", "acceptForSession", "decline", "cancel". +func mapResponseToDecision(resp *PermissionResponse) string { + if resp.Cancelled { + return decisionCancel + } + switch resp.OptionID { + case "approve", "allow", decisionAccept: + return decisionAccept + case "approveAlways", "allowAlways", decisionAcceptSession: + return decisionAcceptSession + case "reject", "deny", decisionDecline: + return decisionDecline + case decisionCancel: + return decisionCancel + default: + if resp.OptionID != "" { + return resp.OptionID + } + return decisionAccept + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go new file mode 100644 index 0000000000..7fa55be635 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go @@ -0,0 +1,227 @@ +package codex + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/types/streams" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/kandev/kandev/pkg/codex" + "go.uber.org/zap" +) + +// Prompt sends a prompt to the agent, starting a new turn. +// This method blocks until the turn completes (turn/completed notification received). +func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { + a.mu.Lock() + client := a.client + threadID := a.threadID + // Reset accumulators for new turn + a.messageBuffer = "" + a.reasoningBuffer = "" + a.currentReasoningItemID = "" + // Create channel to wait for turn completion + a.turnCompleteCh = make(chan turnCompleteResult, 1) + a.mu.Unlock() + + if client == nil { + return fmt.Errorf("adapter not initialized") + } + + inputs, err := a.buildPromptInputs(message, attachments) + if err != nil { + return err + } + + a.logger.Info("sending prompt", + zap.String("thread_id", threadID), + zap.Int("inputs", len(inputs)), + zap.Int("image_attachments", len(attachments))) + + turnID, completeCh, err := a.startTurn(ctx, client, threadID, inputs) + if err != nil { + return err + } + + return a.waitForTurnCompletion(ctx, threadID, turnID, completeCh) +} + +// buildPromptInputs constructs the list of UserInput items from message text and attachments. +func (a *Adapter) buildPromptInputs(message string, attachments []v1.MessageAttachment) ([]codex.UserInput, error) { + inputs := make([]codex.UserInput, 0, 1+len(attachments)) + if strings.TrimSpace(message) != "" { + inputs = append(inputs, codex.UserInput{Type: "text", Text: message}) + } + + if len(attachments) > 0 { + imagePaths, err := a.saveImageAttachments(a.cfg.WorkDir, attachments) + if err != nil { + a.logger.Warn("failed to save image attachments", zap.Error(err)) + } else { + for _, imagePath := range imagePaths { + inputs = append(inputs, codex.UserInput{Type: "localImage", Path: imagePath}) + } + } + } + + if len(inputs) == 0 { + return nil, fmt.Errorf("prompt requires message text or attachments") + } + return inputs, nil +} + +// startTurn calls turn/start and returns the turn ID and completion channel. +func (a *Adapter) startTurn(ctx context.Context, client *codex.Client, threadID string, inputs []codex.UserInput) (string, chan turnCompleteResult, error) { + params := &codex.TurnStartParams{ + ThreadID: threadID, + Input: inputs, + } + + resp, err := client.Call(ctx, codex.MethodTurnStart, params) + if err != nil { + a.mu.Lock() + a.turnCompleteCh = nil + a.mu.Unlock() + return "", nil, fmt.Errorf("failed to start turn: %w", err) + } + + if resp.Error != nil { + a.mu.Lock() + a.turnCompleteCh = nil + a.mu.Unlock() + return "", nil, fmt.Errorf("turn start error: %s", resp.Error.Message) + } + + var result codex.TurnStartResult + if err := json.Unmarshal(resp.Result, &result); err != nil { + a.logger.Warn("failed to parse turn start result", zap.Error(err), zap.String("raw", string(resp.Result))) + } + + turnID := "" + if result.Turn != nil { + turnID = result.Turn.ID + } + + a.mu.Lock() + a.turnID = turnID + completeCh := a.turnCompleteCh + a.mu.Unlock() + + if result.Turn != nil { + a.logger.Info("started turn, waiting for completion", zap.String("turn_id", turnID), zap.String("status", result.Turn.Status)) + } else { + a.logger.Info("started turn, waiting for completion", zap.String("turn_id", turnID)) + } + + return turnID, completeCh, nil +} + +// waitForTurnCompletion blocks until the turn completes or context is cancelled. +func (a *Adapter) waitForTurnCompletion(ctx context.Context, threadID, turnID string, completeCh chan turnCompleteResult) error { + select { + case <-ctx.Done(): + a.mu.Lock() + a.turnCompleteCh = nil + a.mu.Unlock() + return ctx.Err() + case completeResult := <-completeCh: + a.mu.Lock() + a.turnCompleteCh = nil + a.mu.Unlock() + if !completeResult.success && completeResult.err != "" { + return fmt.Errorf("turn failed: %s", completeResult.err) + } + a.logger.Info("turn completed", zap.String("turn_id", turnID), zap.Bool("success", completeResult.success)) + + // Emit complete event via the stream. + // This normalizes Codex behavior to match other adapters. + // All adapters now emit complete events, eliminating the need for protocol-specific flags. + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeComplete, + SessionID: threadID, + OperationID: turnID, + }) + + return nil + } +} + +// saveImageAttachments saves image attachments to temp files in the workspace. +func (a *Adapter) saveImageAttachments(workDir string, attachments []v1.MessageAttachment) ([]string, error) { + var imagePaths []string + + if workDir == "" { + return nil, fmt.Errorf("workDir is required to save attachments") + } + + tempDir := filepath.Join(workDir, ".kandev", "temp", "images") + if err := os.MkdirAll(tempDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + + for _, att := range attachments { + if att.Type != "image" { + continue + } + + imageData, err := base64.StdEncoding.DecodeString(att.Data) + if err != nil { + a.logger.Warn("failed to decode image attachment", zap.Error(err)) + continue + } + + ext := ".png" + switch att.MimeType { + case "image/jpeg": + ext = ".jpg" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + case "image/png": + ext = ".png" + } + + filename := fmt.Sprintf("image-%s%s", uuid.New().String()[:8], ext) + filePath := filepath.Join(tempDir, filename) + if err := os.WriteFile(filePath, imageData, 0644); err != nil { + a.logger.Warn("failed to write image file", zap.Error(err), zap.String("path", filePath)) + continue + } + + imagePaths = append(imagePaths, filePath) + a.logger.Info("saved image attachment", + zap.String("path", filePath), + zap.Int("size", len(imageData))) + } + + return imagePaths, nil +} + +// Cancel interrupts the current turn. +func (a *Adapter) Cancel(ctx context.Context) error { + a.mu.RLock() + client := a.client + threadID := a.threadID + turnID := a.turnID + a.mu.RUnlock() + + if client == nil { + return fmt.Errorf("adapter not initialized") + } + + a.logger.Info("cancelling turn", zap.String("thread_id", threadID), zap.String("turn_id", turnID)) + + // Codex uses turn/interrupt to cancel + _, err := client.Call(ctx, codex.MethodTurnInterrupt, map[string]string{ + "threadId": threadID, + "turnId": turnID, + }) + return err +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_session.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_session.go new file mode 100644 index 0000000000..9dcfa21cab --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_session.go @@ -0,0 +1,120 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/pkg/codex" + "go.uber.org/zap" +) + +// NewSession creates a new Codex thread (session). +// Note: The mcpServers parameter is ignored because Codex reads MCP configuration from +// ~/.codex/config.toml at startup time, not through the protocol. MCP servers are written +// to the config file by PrepareEnvironment() before the Codex process starts. +func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { + // Check client under lock, but don't hold lock during Call() to avoid deadlock + // with handleNotification which also needs the lock + a.mu.RLock() + client := a.client + a.mu.RUnlock() + + if client == nil { + return "", fmt.Errorf("adapter not initialized") + } + + // Determine approval policy - default to "untrusted" if not specified. + // "untrusted" forces Codex to request approval for all commands/writes. + // Other options: "on-failure", "on-request", "never" + approvalPolicy := a.cfg.ApprovalPolicy + if approvalPolicy == "" { + approvalPolicy = "untrusted" + } + + a.logger.Info("starting codex thread with approval policy", + zap.String("approval_policy", approvalPolicy), + zap.String("work_dir", a.cfg.WorkDir)) + + resp, err := client.Call(ctx, codex.MethodThreadStart, &codex.ThreadStartParams{ + Cwd: a.cfg.WorkDir, + ApprovalPolicy: approvalPolicy, // "untrusted", "on-failure", "on-request", "never" + SandboxPolicy: &codex.SandboxPolicy{ + Type: "workspace-write", // Sandbox to workspace only (kebab-case per Codex docs) + WritableRoots: []string{a.cfg.WorkDir}, // Allow writing to workspace + NetworkAccess: true, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to start thread: %w", err) + } + if resp.Error != nil { + return "", fmt.Errorf("thread start error: %s", resp.Error.Message) + } + + var result codex.ThreadStartResult + if err := json.Unmarshal(resp.Result, &result); err != nil { + return "", fmt.Errorf("failed to parse thread start result: %w", err) + } + + a.mu.Lock() + a.threadID = result.Thread.ID + a.mu.Unlock() + + a.logger.Info("created new thread", zap.String("thread_id", a.threadID)) + + return a.threadID, nil +} + +// LoadSession resumes an existing Codex thread. +// It passes the same approval policy and sandbox settings as NewSession to ensure +// permission requirements are preserved across resume (see openai/codex#5322). +func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { + // Check client under lock, but don't hold lock during Call() to avoid deadlock + // with handleNotification which also needs the lock + a.mu.RLock() + client := a.client + a.mu.RUnlock() + + if client == nil { + return fmt.Errorf("adapter not initialized") + } + + // Determine approval policy - same logic as NewSession + // "untrusted" forces Codex to request approval for all commands/writes. + approvalPolicy := a.cfg.ApprovalPolicy + if approvalPolicy == "" { + approvalPolicy = "untrusted" + } + + a.logger.Info("resuming codex thread with approval policy", + zap.String("thread_id", sessionID), + zap.String("approval_policy", approvalPolicy), + zap.String("work_dir", a.cfg.WorkDir)) + + resp, err := client.Call(ctx, codex.MethodThreadResume, &codex.ThreadResumeParams{ + ThreadID: sessionID, + Cwd: a.cfg.WorkDir, + ApprovalPolicy: approvalPolicy, + SandboxPolicy: &codex.SandboxPolicy{ + Type: "workspace-write", // kebab-case per Codex docs + WritableRoots: []string{a.cfg.WorkDir}, + NetworkAccess: true, + }, + }) + if err != nil { + return fmt.Errorf("failed to resume thread: %w", err) + } + if resp.Error != nil { + return fmt.Errorf("thread resume error: %s", resp.Error.Message) + } + + a.mu.Lock() + a.threadID = sessionID + a.mu.Unlock() + + a.logger.Info("resumed thread", zap.String("thread_id", a.threadID)) + + return nil +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go index ad8fbd8197..b9a359ec96 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go @@ -26,6 +26,66 @@ type CodexParsedError struct { ResetsInSeconds int64 } +// extractErrorFields extracts errorType, errorMessage, and resetsInSeconds +// from a parsed JSON map, checking both nested "error" object and top-level fields. +func extractErrorFields(rawData map[string]any) (errorType, errorMessage string, resetsInSeconds int64) { + // First, check for nested error object (standard format) + if errObj, ok := rawData["error"].(map[string]any); ok { + if t, ok := errObj["type"].(string); ok { + errorType = t + } + if m, ok := errObj["message"].(string); ok { + errorMessage = m + } + // JSON numbers are float64 in Go + if r, ok := errObj["resets_in_seconds"].(float64); ok { + resetsInSeconds = int64(r) + } + } + + // Fall back to top-level fields if nested fields not found (flat error format) + if errorMessage == "" { + if m, ok := rawData["message"].(string); ok { + errorMessage = m + } + } + if errorType == "" { + if t, ok := rawData["type"].(string); ok { + errorType = t + } + } + return errorType, errorMessage, resetsInSeconds +} + +// appendResetTime appends a human-readable reset duration to msg when resetsInSeconds > 0. +func appendResetTime(msg string, resetsInSeconds int64) string { + if resetsInSeconds <= 0 { + return msg + } + duration := time.Duration(resetsInSeconds) * time.Second + switch { + case duration.Hours() >= 1: + return fmt.Sprintf("%s (resets in %.0f hours)", msg, duration.Hours()) + case duration.Minutes() >= 1: + return fmt.Sprintf("%s (resets in %.0f minutes)", msg, duration.Minutes()) + default: + return fmt.Sprintf("%s (resets in %d seconds)", msg, int(duration.Seconds())) + } +} + +// buildErrorMessage constructs a user-friendly message from the parsed error fields. +func buildErrorMessage(errorMessage, errorType, httpError string, resetsInSeconds int64, rawData map[string]any) string { + switch { + case errorMessage != "": + return appendResetTime(errorMessage, resetsInSeconds) + case errorType != "": + return fmt.Sprintf("Error: %s", errorType) + default: + jsonBytes, _ := json.MarshalIndent(rawData, "", " ") + return fmt.Sprintf("%s\n\n%s", httpError, string(jsonBytes)) + } +} + // ParseCodexStderrError attempts to parse a Codex stderr error line and extract // error information. Returns nil if parsing fails. // @@ -59,66 +119,10 @@ func ParseCodexStderrError(line string) *CodexParsedError { result.RawJSON = rawData - // Extract common fields - check both nested "error" object and top-level fields - var errorType, errorMessage string - var resetsInSeconds int64 - - // First, check for nested error object (standard format) - if errObj, ok := rawData["error"].(map[string]any); ok { - if t, ok := errObj["type"].(string); ok { - errorType = t - } - if m, ok := errObj["message"].(string); ok { - errorMessage = m - } - // Handle both int and float64 (JSON numbers are float64 in Go) - if r, ok := errObj["resets_in_seconds"].(float64); ok { - resetsInSeconds = int64(r) - } - } - - // Fall back to top-level fields if nested fields not found (flat error format) - if errorMessage == "" { - if m, ok := rawData["message"].(string); ok { - errorMessage = m - } - } - if errorType == "" { - if t, ok := rawData["type"].(string); ok { - errorType = t - } - } - + errorType, errorMessage, resetsInSeconds := extractErrorFields(rawData) result.ErrorType = errorType result.ResetsInSeconds = resetsInSeconds - - // Build a user-friendly message from available fields - var msg string - - if errorMessage != "" { - // We have a specific error message from the API - msg = errorMessage - // Add reset time info if available - if resetsInSeconds > 0 { - duration := time.Duration(resetsInSeconds) * time.Second - if duration.Hours() >= 1 { - msg = fmt.Sprintf("%s (resets in %.0f hours)", msg, duration.Hours()) - } else if duration.Minutes() >= 1 { - msg = fmt.Sprintf("%s (resets in %.0f minutes)", msg, duration.Minutes()) - } else { - msg = fmt.Sprintf("%s (resets in %d seconds)", msg, int(duration.Seconds())) - } - } - } else if errorType != "" { - // We have an error type but no message - msg = fmt.Sprintf("Error: %s", errorType) - } else { - // Unknown error format - show HTTP error and include JSON body - jsonBytes, _ := json.MarshalIndent(rawData, "", " ") - msg = fmt.Sprintf("%s\n\n%s", httpError, string(jsonBytes)) - } - - result.Message = msg + result.Message = buildErrorMessage(errorMessage, errorType, httpError, resetsInSeconds, rawData) return result } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go index 5077d2f590..e499e9aad1 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go @@ -582,6 +582,167 @@ func (a *Adapter) handleSDKEvent(event *opencode.SDKEventEnvelope) { } } +// resolvePartID returns the part ID to use, falling back to a composite key. +func resolvePartID(partID, messageID, suffix string) string { + if partID != "" { + return partID + } + return messageID + ":" + suffix +} + +// getOrCreateTextPartState fetches or initializes the textPartState for a given part ID. +// Must be called with a.mu.Lock() held. +func (a *Adapter) getOrCreateTextPartState(partID string) *textPartState { + if a.textParts == nil { + a.textParts = make(map[string]*textPartState) + } + state := a.textParts[partID] + if state == nil { + state = &textPartState{lastTextLen: 0} + a.textParts[partID] = state + } + return state +} + +// computeIncrementalText returns the new text to emit, advancing the state. +// Must be called with a.mu.Lock() held (state is modified in place). +func computeIncrementalText(state *textPartState, cumulativeText, delta string) string { + if cumulativeText != "" { + if len(cumulativeText) > state.lastTextLen { + text := cumulativeText[state.lastTextLen:] + state.lastTextLen = len(cumulativeText) + return text + } + return "" + } + // No cumulative text available: use delta only for first chunk + if delta != "" && state.lastTextLen == 0 { + return delta + } + return "" +} + +// handleTextPart processes a text part update, returning any new text to emit. +func (a *Adapter) handleTextPart(part opencode.Part, delta string) string { + partID := resolvePartID(part.ID, part.MessageID, "text") + + a.mu.Lock() + state := a.getOrCreateTextPartState(partID) + textToSend := computeIncrementalText(state, part.Text, delta) + a.mu.Unlock() + + return textToSend +} + +// handleReasoningPart processes a reasoning part update, returning any new text to emit. +func (a *Adapter) handleReasoningPart(part opencode.Part, delta string) string { + partID := resolvePartID(part.ID, part.MessageID, "reasoning") + + a.mu.Lock() + state := a.getOrCreateTextPartState(partID) + textToSend := computeIncrementalText(state, part.Text, delta) + a.mu.Unlock() + + return textToSend +} + +// handleToolPart processes a tool part update and emits the appropriate event. +func (a *Adapter) handleToolPart(part opencode.Part, sessionID, operationID string) { + if part.State == nil { + return + } + + toolCallID := part.CallID + toolName := part.Tool + state := part.State + + // Determine status + status := normalizeToolStatus(state.Status) + + // Build ToolState for normalization (do this first so we can use input for title) + toolState := buildToolState(state) + + // Generate normalized payload + normalizedPayload := a.normalizer.NormalizeToolCall(toolName, toolState) + + // Determine title: prefer derived title (command/path), then state.Title, fallback to toolName + title := deriveToolTitle(toolName, toolState.Input) + if title == "" { + title = state.Title + } + if title == "" { + title = toolName + } + + // Track if we've seen this tool call before + a.mu.Lock() + if a.seenToolCalls == nil { + a.seenToolCalls = make(map[string]bool) + } + isFirstEvent := !a.seenToolCalls[toolCallID] + a.seenToolCalls[toolCallID] = true + a.mu.Unlock() + + // Emit tool_call for the first event, tool_update for subsequent events + if isFirstEvent { + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolName: toolName, + ToolTitle: title, + ToolStatus: status, + NormalizedPayload: normalizedPayload, + }) + } else { + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolName: toolName, + ToolTitle: title, + ToolStatus: status, + Error: state.Error, + NormalizedPayload: normalizedPayload, + }) + } +} + +// normalizeToolStatus maps OpenCode tool status values to frontend-compatible strings. +func normalizeToolStatus(status string) string { + switch status { + case opencode.ToolStatusPending: + return "pending" + case opencode.ToolStatusRunning: + return "running" + case opencode.ToolStatusCompleted: + return "complete" // Normalized to "complete" for frontend consistency + case opencode.ToolStatusError: + return "error" + default: + return status + } +} + +// buildToolState constructs a ToolState from OpenCode's tool part state. +func buildToolState(state *opencode.ToolStateUpdate) *ToolState { + toolState := &ToolState{ + Output: state.Output, + Title: state.Title, + } + if state.Input != nil { + toolState.Input = make(map[string]any) + _ = json.Unmarshal(state.Input, &toolState.Input) // Ignore error - input is optional + } + if state.Metadata != nil { + toolState.Metadata = make(map[string]any) + _ = json.Unmarshal(state.Metadata, &toolState.Metadata) // Ignore error - metadata is optional + } + return toolState +} + // handleMessagePartUpdated processes message.part.updated events func (a *Adapter) handleMessagePartUpdated(props json.RawMessage, sessionID, operationID string) { parsed, err := opencode.ParseMessagePartUpdated(props) @@ -606,41 +767,7 @@ func (a *Adapter) handleMessagePartUpdated(props json.RawMessage, sessionID, ope switch part.Type { case opencode.PartTypeText: - // Track text parts by ID to compute incremental text - // IMPORTANT: Always prefer cumulative part.Text over delta to avoid duplication - // The delta may be sent multiple times with the same content - partID := part.ID - if partID == "" { - // Fallback: use MessageID + type as unique key - partID = part.MessageID + ":text" - } - - a.mu.Lock() - if a.textParts == nil { - a.textParts = make(map[string]*textPartState) - } - state := a.textParts[partID] - if state == nil { - state = &textPartState{lastTextLen: 0} - a.textParts[partID] = state - } - - // Determine what text to send - prefer cumulative text to avoid duplication - var textToSend string - if part.Text != "" { - // Use cumulative text - compute what's new since last time - if len(part.Text) > state.lastTextLen { - textToSend = part.Text[state.lastTextLen:] - state.lastTextLen = len(part.Text) - } - } else if parsed.Delta != "" && state.lastTextLen == 0 { - // No cumulative text available, use delta only for first chunk - // This handles streaming before cumulative text is populated - textToSend = parsed.Delta - } - a.mu.Unlock() - - if textToSend != "" { + if textToSend := a.handleTextPart(part, parsed.Delta); textToSend != "" { a.sendUpdate(AgentEvent{ Type: streams.EventTypeMessageChunk, SessionID: sessionID, @@ -650,39 +777,7 @@ func (a *Adapter) handleMessagePartUpdated(props json.RawMessage, sessionID, ope } case opencode.PartTypeReasoning: - // Track reasoning parts by ID to compute incremental text - // IMPORTANT: Always prefer cumulative part.Text over delta to avoid duplication - partID := part.ID - if partID == "" { - // Fallback: use MessageID + type as unique key - partID = part.MessageID + ":reasoning" - } - - a.mu.Lock() - if a.textParts == nil { - a.textParts = make(map[string]*textPartState) - } - state := a.textParts[partID] - if state == nil { - state = &textPartState{lastTextLen: 0} - a.textParts[partID] = state - } - - // Determine what text to send - prefer cumulative text to avoid duplication - var textToSend string - if part.Text != "" { - // Use cumulative text - compute what's new since last time - if len(part.Text) > state.lastTextLen { - textToSend = part.Text[state.lastTextLen:] - state.lastTextLen = len(part.Text) - } - } else if parsed.Delta != "" && state.lastTextLen == 0 { - // No cumulative text available, use delta only for first chunk - textToSend = parsed.Delta - } - a.mu.Unlock() - - if textToSend != "" { + if textToSend := a.handleReasoningPart(part, parsed.Delta); textToSend != "" { a.sendUpdate(AgentEvent{ Type: streams.EventTypeReasoning, SessionID: sessionID, @@ -692,93 +787,7 @@ func (a *Adapter) handleMessagePartUpdated(props json.RawMessage, sessionID, ope } case opencode.PartTypeTool: - // Tool call - if part.State == nil { - return - } - - toolCallID := part.CallID - toolName := part.Tool - state := part.State - - // Determine status - var status string - switch state.Status { - case opencode.ToolStatusPending: - status = "pending" - case opencode.ToolStatusRunning: - status = "running" - case opencode.ToolStatusCompleted: - status = "complete" // Normalized to "complete" for frontend consistency - case opencode.ToolStatusError: - status = "error" - default: - status = state.Status - } - - // Build ToolState for normalization (do this first so we can use input for title) - toolState := &ToolState{ - Output: state.Output, - Title: state.Title, - } - if state.Input != nil { - toolState.Input = make(map[string]any) - _ = json.Unmarshal(state.Input, &toolState.Input) // Ignore error - input is optional - } - if state.Metadata != nil { - toolState.Metadata = make(map[string]any) - _ = json.Unmarshal(state.Metadata, &toolState.Metadata) // Ignore error - metadata is optional - } - - // Generate normalized payload - normalizedPayload := a.normalizer.NormalizeToolCall(toolName, toolState) - - // Determine title: prefer derived title (command/path), then state.Title, fallback to toolName - // For tools like bash, the command is more useful than the description - title := deriveToolTitle(toolName, toolState.Input) - if title == "" { - title = state.Title - } - if title == "" { - title = toolName - } - - // Track if we've seen this tool call before - a.mu.Lock() - if a.seenToolCalls == nil { - a.seenToolCalls = make(map[string]bool) - } - isFirstEvent := !a.seenToolCalls[toolCallID] - a.seenToolCalls[toolCallID] = true - a.mu.Unlock() - - // Emit tool_call for the first event we see for this tool call - // This creates the message in the database - if isFirstEvent { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolName: toolName, - ToolTitle: title, - ToolStatus: status, - NormalizedPayload: normalizedPayload, - }) - } else { - // For subsequent events, emit tool_update to update the existing message - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolName: toolName, - ToolTitle: title, - ToolStatus: status, - Error: state.Error, - NormalizedPayload: normalizedPayload, - }) - } + a.handleToolPart(part, sessionID, operationID) } } @@ -895,14 +904,7 @@ func (a *Adapter) handlePermissionAsked(props json.RawMessage, sessionID string) a.mu.Unlock() // Format title from metadata if available - title := fmt.Sprintf("Permission: %s", permission) - if parsed.Metadata != nil { - if cmd, ok := parsed.Metadata["command"].(string); ok { - title = fmt.Sprintf("Execute: %s", cmd) - } else if path, ok := parsed.Metadata["path"].(string); ok { - title = fmt.Sprintf("%s: %s", permission, path) - } - } + title := formatPermissionTitle(permission, parsed.Metadata) // If we have a handler, call it to get user approval. // The handler (process manager's handlePermissionRequest) will: @@ -944,6 +946,19 @@ func (a *Adapter) handlePermissionAsked(props json.RawMessage, sessionID string) } } +// formatPermissionTitle builds a display title for a permission request. +func formatPermissionTitle(permission string, metadata map[string]any) string { + if metadata != nil { + if cmd, ok := metadata["command"].(string); ok { + return fmt.Sprintf("Execute: %s", cmd) + } + if path, ok := metadata["path"].(string); ok { + return fmt.Sprintf("%s: %s", permission, path) + } + } + return fmt.Sprintf("Permission: %s", permission) +} + // RespondToPermission responds to a permission request (called via handler) func (a *Adapter) RespondToPermission(ctx context.Context, requestID string, approved bool, message string) error { a.mu.Lock() @@ -963,6 +978,69 @@ func (a *Adapter) RespondToPermission(ctx context.Context, requestID string, app return a.client.ReplyPermission(ctx, requestID, reply, msg) } +// openCodeConfigPath returns the path to the OpenCode config file. +func openCodeConfigPath(homeDir string) (string, string, error) { + if homeDir == "" { + var err error + homeDir, err = os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("failed to get user home directory: %w", err) + } + } + configDir := filepath.Join(homeDir, ".config", "opencode") + configPath := filepath.Join(configDir, "opencode.json") + return configDir, configPath, nil +} + +// loadOpenCodeConfig reads and parses the existing OpenCode config file. +// Returns an empty map if the file doesn't exist. +func loadOpenCodeConfig(configPath string, log *logger.Logger) (map[string]interface{}, error) { + existingData, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to read existing opencode config: %w", err) + } + + var rawConfig map[string]interface{} + if len(existingData) == 0 { + return make(map[string]interface{}), nil + } + + if err := json.Unmarshal(existingData, &rawConfig); err != nil { + if log != nil { + log.Warn("failed to parse existing opencode config, will create new", + zap.String("path", configPath), + zap.Error(err)) + } + return make(map[string]interface{}), nil + } + return rawConfig, nil +} + +// buildMCPServerConfig builds the OpenCode server config entry for a single MCP server. +func buildMCPServerConfig(server shared.McpServerConfig) map[string]interface{} { + serverConfig := make(map[string]interface{}) + + if server.Type == "sse" || server.Type == "http" || server.Type == "remote" { + // Remote transport - use url field + serverConfig["type"] = "remote" + if server.URL != "" { + serverConfig["url"] = server.URL + } + } else { + // Local/stdio transport - use command and args + serverConfig["type"] = "local" + if server.Command != "" { + // OpenCode expects command as an array: ["command", "arg1", "arg2"] + cmdArray := []string{server.Command} + cmdArray = append(cmdArray, server.Args...) + serverConfig["command"] = cmdArray + } + } + + serverConfig["enabled"] = true + return serverConfig +} + // WriteOpenCodeMcpConfig writes MCP server configuration to OpenCode's config file. // It merges with existing config, preserving other settings and existing MCP servers. // OpenCode reads MCP servers from ~/.config/opencode/opencode.json at startup time. @@ -973,41 +1051,19 @@ func WriteOpenCodeMcpConfig(mcpServers []shared.McpServerConfig, homeDir string, return nil // Nothing to configure } - // Determine config directory - if homeDir == "" { - var err error - homeDir, err = os.UserHomeDir() - if err != nil { - return fmt.Errorf("failed to get user home directory: %w", err) - } + configDir, configPath, err := openCodeConfigPath(homeDir) + if err != nil { + return err } - configDir := filepath.Join(homeDir, ".config", "opencode") - configPath := filepath.Join(configDir, "opencode.json") // Create config directory if it doesn't exist if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("failed to create opencode config directory: %w", err) } - // Read existing config if it exists - existingData, err := os.ReadFile(configPath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read existing opencode config: %w", err) - } - - // Parse existing config into a generic map to preserve all fields - var rawConfig map[string]interface{} - if len(existingData) > 0 { - if err := json.Unmarshal(existingData, &rawConfig); err != nil { - if log != nil { - log.Warn("failed to parse existing opencode config, will create new", - zap.String("path", configPath), - zap.Error(err)) - } - rawConfig = make(map[string]interface{}) - } - } else { - rawConfig = make(map[string]interface{}) + rawConfig, err := loadOpenCodeConfig(configPath, log) + if err != nil { + return err } // Get or create mcp section @@ -1018,32 +1074,9 @@ func WriteOpenCodeMcpConfig(mcpServers []shared.McpServerConfig, homeDir string, // Add/update our MCP servers for _, server := range mcpServers { - serverConfig := make(map[string]interface{}) - - if server.Type == "sse" || server.Type == "http" || server.Type == "remote" { - // Remote transport - use url field - serverConfig["type"] = "remote" - if server.URL != "" { - serverConfig["url"] = server.URL - } - } else { - // Local/stdio transport - use command and args - serverConfig["type"] = "local" - if server.Command != "" { - // OpenCode expects command as an array: ["command", "arg1", "arg2"] - cmdArray := []string{server.Command} - cmdArray = append(cmdArray, server.Args...) - serverConfig["command"] = cmdArray - } - } - - // Always enable the server - serverConfig["enabled"] = true - - mcpSection[server.Name] = serverConfig + mcpSection[server.Name] = buildMCPServerConfig(server) } - // Update the mcp section in the config rawConfig["mcp"] = mcpSection // Marshal back to JSON with indentation diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go index d60073cb89..e72c183a7b 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go @@ -69,6 +69,7 @@ func (n *Normalizer) NormalizeToolCall(toolName string, state *ToolState) *strea } // NormalizeToolResult updates the payload with tool result data. +// // Deprecated: Result data is now handled in NormalizeToolCall via ToolState func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { // Keep for backwards compatibility but normalization now happens in NormalizeToolCall @@ -171,37 +172,45 @@ func (n *Normalizer) normalizeGlob(state *ToolState) *streams.NormalizedPayload payload := streams.NewCodeSearch("", pattern, path, pattern) // Parse output (newline-separated file list) and metadata - if state.Output != "" || state.Metadata != nil { - payload.CodeSearch().Output = &streams.CodeSearchOutput{} - - // Parse file list from output - if state.Output != "" { - lines := strings.Split(strings.TrimSpace(state.Output), "\n") - // Filter out empty lines and the truncation message - var files []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" && !strings.HasPrefix(line, "(Results are truncated") { - files = append(files, line) - } - } - payload.CodeSearch().Output.Files = files - } - - // Extract metadata - if state.Metadata != nil { - if count, ok := state.Metadata["count"].(float64); ok { - payload.CodeSearch().Output.FileCount = int(count) - } - if truncated, ok := state.Metadata["truncated"].(bool); ok { - payload.CodeSearch().Output.Truncated = truncated - } - } + if state.Output == "" && state.Metadata == nil { + return payload } + payload.CodeSearch().Output = &streams.CodeSearchOutput{} + applyGlobOutput(payload.CodeSearch().Output, state) + return payload } +// applyGlobOutput populates CodeSearchOutput from glob tool state. +func applyGlobOutput(out *streams.CodeSearchOutput, state *ToolState) { + if state.Output != "" { + out.Files = parseGlobFiles(state.Output) + } + if state.Metadata == nil { + return + } + if count, ok := state.Metadata["count"].(float64); ok { + out.FileCount = int(count) + } + if truncated, ok := state.Metadata["truncated"].(bool); ok { + out.Truncated = truncated + } +} + +// parseGlobFiles splits newline-separated output into a file list, filtering empty lines. +func parseGlobFiles(output string) []string { + lines := strings.Split(strings.TrimSpace(output), "\n") + var files []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "(Results are truncated") { + files = append(files, line) + } + } + return files +} + // normalizeGrep converts OpenCode grep tool data to code_search. func (n *Normalizer) normalizeGrep(state *ToolState) *streams.NormalizedPayload { pattern := shared.GetString(state.Input, "pattern") @@ -212,44 +221,54 @@ func (n *Normalizer) normalizeGrep(state *ToolState) *streams.NormalizedPayload payload := streams.NewCodeSearch(query, pattern, path, "") // Parse output and metadata - if state.Output != "" || state.Metadata != nil { - payload.CodeSearch().Output = &streams.CodeSearchOutput{} - - // Parse file list from output (grep typically outputs file:line format) - if state.Output != "" { - lines := strings.Split(strings.TrimSpace(state.Output), "\n") - fileSet := make(map[string]bool) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // Extract file path (before first colon for grep output) - if idx := strings.Index(line, ":"); idx > 0 { - fileSet[line[:idx]] = true - } else { - fileSet[line] = true - } - } - var files []string - for f := range fileSet { - files = append(files, f) - } - payload.CodeSearch().Output.Files = files - payload.CodeSearch().Output.FileCount = len(files) - } - - // Extract metadata - if state.Metadata != nil { - if truncated, ok := state.Metadata["truncated"].(bool); ok { - payload.CodeSearch().Output.Truncated = truncated - } - } + if state.Output == "" && state.Metadata == nil { + return payload } + payload.CodeSearch().Output = &streams.CodeSearchOutput{} + applyGrepOutput(payload.CodeSearch().Output, state) + return payload } +// applyGrepOutput populates CodeSearchOutput from grep tool state. +func applyGrepOutput(out *streams.CodeSearchOutput, state *ToolState) { + if state.Output != "" { + files := parseGrepFiles(state.Output) + out.Files = files + out.FileCount = len(files) + } + if state.Metadata == nil { + return + } + if truncated, ok := state.Metadata["truncated"].(bool); ok { + out.Truncated = truncated + } +} + +// parseGrepFiles extracts unique file paths from grep output lines. +func parseGrepFiles(output string) []string { + lines := strings.Split(strings.TrimSpace(output), "\n") + fileSet := make(map[string]bool) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Extract file path (before first colon for grep output) + if idx := strings.Index(line, ":"); idx > 0 { + fileSet[line[:idx]] = true + } else { + fileSet[line] = true + } + } + var files []string + for f := range fileSet { + files = append(files, f) + } + return files +} + // normalizeRead converts OpenCode read tool data. func (n *Normalizer) normalizeRead(state *ToolState) *streams.NormalizedPayload { filePath := shared.GetString(state.Input, "path") @@ -264,27 +283,33 @@ func (n *Normalizer) normalizeRead(state *ToolState) *streams.NormalizedPayload payload := streams.NewReadFile(filePath, offset, limit) // Add output if available - if state.Output != "" || state.Metadata != nil { - payload.ReadFile().Output = &streams.ReadFileOutput{ - Content: state.Output, - } + if state.Output == "" && state.Metadata == nil { + return payload + } - if state.Metadata != nil { - if lineCount, ok := state.Metadata["line_count"].(float64); ok { - payload.ReadFile().Output.LineCount = int(lineCount) - } - if truncated, ok := state.Metadata["truncated"].(bool); ok { - payload.ReadFile().Output.Truncated = truncated - } - } + payload.ReadFile().Output = &streams.ReadFileOutput{ + Content: state.Output, + } + applyReadMetadata(payload.ReadFile().Output, state) - // Calculate line count from content if not in metadata - if payload.ReadFile().Output.LineCount == 0 && state.Output != "" { - payload.ReadFile().Output.LineCount = strings.Count(state.Output, "\n") + 1 + return payload +} + +// applyReadMetadata fills ReadFileOutput from read tool metadata and content. +func applyReadMetadata(out *streams.ReadFileOutput, state *ToolState) { + if state.Metadata != nil { + if lineCount, ok := state.Metadata["line_count"].(float64); ok { + out.LineCount = int(lineCount) + } + if truncated, ok := state.Metadata["truncated"].(bool); ok { + out.Truncated = truncated } } - return payload + // Calculate line count from content if not in metadata + if out.LineCount == 0 && state.Output != "" { + out.LineCount = strings.Count(state.Output, "\n") + 1 + } } // normalizeGeneric wraps unknown tools as generic. @@ -301,6 +326,7 @@ func (n *Normalizer) normalizeGeneric(toolName string, state *ToolState) *stream } // normalizeBashResult updates bash payload with result data. +// // Deprecated: Use ToolState in NormalizeToolCall instead func (n *Normalizer) normalizeBashResult(payload *streams.ShellExecPayload, result any) { if payload.Output == nil { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go index a641c8b644..505d910c7b 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go @@ -60,7 +60,7 @@ func LogRawEvent(protocol, agentID, eventType string, rawData json.RawMessage) { "protocol": protocol, "agent": agentID, "event": eventType, - "data": json.RawMessage(rawData), + "data": rawData, } logFile := filepath.Join(debugLogDir, fmt.Sprintf("raw-%s-%s.jsonl", protocol, agentID)) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go index d3d563468c..64f4804fee 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go @@ -85,6 +85,9 @@ const MaxContentLength = 50000 // MaxFileCount is the maximum number of files to include in code search results. const MaxFileCount = 500 +// langPlaintext is the fallback language identifier for files without a known extension. +const langPlaintext = "plaintext" + // TruncateIfNeeded truncates a string if it exceeds maxLen. func TruncateIfNeeded(s string, maxLen int) (string, bool) { if len(s) <= maxLen { @@ -149,7 +152,7 @@ func NormalizeModifyResult(payload *streams.ModifyFilePayload, result string) { // Used for syntax highlighting in diffs. func DetectLanguage(path string) string { if path == "" { - return "plaintext" + return langPlaintext } // Find last dot @@ -165,7 +168,7 @@ func DetectLanguage(path string) string { } if lastDot == -1 || lastDot == len(path)-1 { - return "plaintext" + return langPlaintext } ext := path[lastDot+1:] @@ -196,5 +199,5 @@ func DetectLanguage(path string) string { if lang, ok := langMap[ext]; ok { return lang } - return "plaintext" + return langPlaintext } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go index 6b5105689b..d13cc89a1f 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go @@ -4,22 +4,15 @@ package streamjson import ( "context" - "encoding/base64" "encoding/json" "fmt" "io" - "os" - "path/filepath" - "strings" "sync" - "time" - "github.com/google/uuid" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" - v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/pkg/claudecode" "go.uber.org/zap" ) @@ -136,69 +129,6 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { } } -// PrepareEnvironment performs protocol-specific setup before the agent process starts. -// Stream-json protocol reads MCP configuration from settings files, but we handle MCP via kandev's -// built-in MCP server, so this is a no-op. -func (a *Adapter) PrepareEnvironment() (map[string]string, error) { - a.logger.Info("PrepareEnvironment called", - zap.Int("mcp_server_count", len(a.cfg.McpServers))) - // MCP configuration is handled externally or via CLI flags - return nil, nil -} - -// PrepareCommandArgs returns extra command-line arguments for the agent process. -// For stream-json (Claude Code), MCP configuration is passed via --mcp-config flag. -func (a *Adapter) PrepareCommandArgs() []string { - if len(a.cfg.McpServers) == 0 { - return nil - } - - // Build MCP configuration in Claude Code format - // Format: { "server-name": { "command": "...", "args": [...] } } - mcpConfig := make(map[string]interface{}) - for _, server := range a.cfg.McpServers { - serverDef := make(map[string]interface{}) - - // Handle different transport types - if server.Command != "" { - // stdio transport - serverDef["command"] = server.Command - if len(server.Args) > 0 { - serverDef["args"] = server.Args - } - } else if server.URL != "" { - // SSE/HTTP transport - serverDef["url"] = server.URL - if server.Type != "" { - serverDef["type"] = server.Type - } - } - - mcpConfig[server.Name] = serverDef - } - - // Wrap in mcpServers key (Claude Code expects this format) - wrappedConfig := map[string]interface{}{ - "mcpServers": mcpConfig, - } - - // Convert to JSON string - configJSON, err := json.Marshal(wrappedConfig) - if err != nil { - a.logger.Warn("failed to marshal MCP config, skipping", - zap.Error(err), - zap.Int("server_count", len(a.cfg.McpServers))) - return nil - } - - a.logger.Info("prepared MCP configuration for Claude Code", - zap.Int("server_count", len(a.cfg.McpServers)), - zap.String("config", string(configJSON))) - - // Return --mcp-config flag with JSON string - return []string{"--mcp-config", string(configJSON)} -} - // Connect wires up the stdin/stdout pipes from the running agent subprocess. func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { a.mu.Lock() @@ -213,290 +143,32 @@ func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { return nil } -// Initialize establishes the stream-json connection with the agent subprocess. -func (a *Adapter) Initialize(ctx context.Context) error { - a.logger.Info("initializing stream-json adapter", - zap.String("workdir", a.cfg.WorkDir)) - - // Create Claude Code client - a.client = claudecode.NewClient(a.stdin, a.stdout, a.logger) - a.client.SetRequestHandler(a.handleControlRequest) - a.client.SetMessageHandler(a.handleMessage) - - // Start reading from stdout with the adapter's context - // Wait for the read loop to be ready before sending initialize - readyC := a.client.Start(a.ctx) - select { - case <-readyC: - a.logger.Info("read loop is ready") - case <-ctx.Done(): - return ctx.Err() - case <-time.After(5 * time.Second): - a.logger.Warn("timeout waiting for read loop to start") - } - - // Store agent info (version will be populated from first message) - a.agentInfo = &AgentInfo{ - Name: a.agentID, - Version: "unknown", - } - - // Send initialize control request to get slash commands - // This is required for streaming mode (input-format=stream-json) - initResp, err := a.client.Initialize(ctx, 60*time.Second) - if err != nil { - a.logger.Warn("failed to initialize (continuing anyway)", zap.Error(err)) - } else if initResp != nil && len(initResp.Commands) > 0 { - // Store available commands to emit after session is created - commands := make([]streams.AvailableCommand, len(initResp.Commands)) - for i, cmd := range initResp.Commands { - commands[i] = streams.AvailableCommand{ - Name: cmd.Name, - Description: cmd.Description, - } - } - a.mu.Lock() - a.pendingAvailableCommands = commands - a.mu.Unlock() - - a.logger.Info("received slash commands from initialize", - zap.Int("count", len(commands))) - } - - a.logger.Info("stream-json adapter initialized") - - return nil -} - -// GetAgentInfo returns information about the connected agent. -func (a *Adapter) GetAgentInfo() *AgentInfo { - return a.agentInfo -} - -// emitPendingCommands emits any pending available commands for the given session. -// Must be called after the mutex is unlocked. -func (a *Adapter) emitPendingCommands(sessionID string, commands []streams.AvailableCommand) { - if len(commands) == 0 { - return - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeAvailableCommands, - SessionID: sessionID, - AvailableCommands: commands, - }) - a.logger.Debug("emitted pending slash commands", - zap.String("session_id", sessionID), - zap.Int("count", len(commands))) -} - -// takePendingCommands atomically takes and clears pending commands. -// Must be called with the mutex held. -func (a *Adapter) takePendingCommands() []streams.AvailableCommand { - commands := a.pendingAvailableCommands - a.pendingAvailableCommands = nil - return commands -} - -// NewSession creates a new stream-json session. -// Note: Sessions are created implicitly with the first prompt. -// The mcpServers parameter is ignored as this protocol handles MCP separately. -func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { +// Close releases resources held by the adapter. +func (a *Adapter) Close() error { a.mu.Lock() - sessionID := uuid.New().String() - a.sessionID = sessionID - pendingCommands := a.takePendingCommands() - a.mu.Unlock() + defer a.mu.Unlock() - a.logger.Info("created new session placeholder", zap.String("session_id", sessionID)) - a.emitPendingCommands(sessionID, pendingCommands) - - return sessionID, nil -} - -// LoadSession resumes an existing stream-json session. -// The session ID will be passed to the agent via --resume flag (handled by process manager). -func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { - a.mu.Lock() - a.sessionID = sessionID - pendingCommands := a.takePendingCommands() - a.mu.Unlock() - - a.logger.Info("loaded session", zap.String("session_id", sessionID)) - a.emitPendingCommands(sessionID, pendingCommands) - - return nil -} - -// Prompt sends a prompt and waits for completion. -// Since stream-json protocol doesn't support multimodal content blocks, image attachments -// are saved to temp files in the workspace and the prompt is modified to reference them. -// Claude Code can then read these files with its Read tool. -func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { - a.mu.Lock() - client := a.client - sessionID := a.sessionID - operationID := uuid.New().String() - a.operationID = operationID - workDir := a.cfg.WorkDir - // Create channel to wait for result - a.resultCh = make(chan resultComplete, 1) - a.mu.Unlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - // Handle image attachments by saving to temp files - finalMessage := message - if len(attachments) > 0 { - imagePaths, err := a.saveImageAttachments(workDir, attachments) - if err != nil { - a.logger.Warn("failed to save image attachments", zap.Error(err)) - } else if len(imagePaths) > 0 { - finalMessage = a.buildMessageWithImages(message, imagePaths) - } - } - - a.logger.Info("sending prompt", - zap.String("session_id", sessionID), - zap.String("operation_id", operationID), - zap.Int("attachments", len(attachments))) - - // Send user message - if err := client.SendUserMessage(finalMessage); err != nil { - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return fmt.Errorf("failed to send user message: %w", err) - } - - // Wait for result or context cancellation - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - select { - case <-ctx.Done(): - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return ctx.Err() - case result := <-resultCh: - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - if !result.success && result.err != "" { - return fmt.Errorf("prompt failed: %s", result.err) - } - a.logger.Info("prompt completed", - zap.String("operation_id", operationID), - zap.Bool("success", result.success)) + if a.closed { return nil } -} + a.closed = true -// saveImageAttachments saves image attachments to temp files in the workspace. -// Returns the relative paths to the saved files. -func (a *Adapter) saveImageAttachments(workDir string, attachments []v1.MessageAttachment) ([]string, error) { - var imagePaths []string + a.logger.Info("closing stream-json adapter") - // Create temp directory for images - tempDir := filepath.Join(workDir, ".kandev", "temp", "images") - if err := os.MkdirAll(tempDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create temp directory: %w", err) + // Cancel the context to stop the read loop + if a.cancel != nil { + a.cancel() } - for _, att := range attachments { - if att.Type != "image" { - continue - } - - // Decode base64 data - imageData, err := base64.StdEncoding.DecodeString(att.Data) - if err != nil { - a.logger.Warn("failed to decode image attachment", zap.Error(err)) - continue - } - - // Determine file extension from mime type - ext := ".png" // default - switch att.MimeType { - case "image/jpeg": - ext = ".jpg" - case "image/gif": - ext = ".gif" - case "image/webp": - ext = ".webp" - case "image/png": - ext = ".png" - } - - // Generate unique filename - filename := fmt.Sprintf("image-%s%s", uuid.New().String()[:8], ext) - filePath := filepath.Join(tempDir, filename) - - // Write image to file - if err := os.WriteFile(filePath, imageData, 0644); err != nil { - a.logger.Warn("failed to write image file", zap.Error(err), zap.String("path", filePath)) - continue - } - - // Store relative path from workspace root - relPath, err := filepath.Rel(workDir, filePath) - if err != nil { - relPath = filePath // Fall back to absolute path - } - - imagePaths = append(imagePaths, relPath) - a.logger.Info("saved image attachment", - zap.String("path", relPath), - zap.Int("size", len(imageData))) + // Stop the client + if a.client != nil { + a.client.Stop() } - return imagePaths, nil -} + // Close update channel + close(a.updatesCh) -// buildMessageWithImages prepends image file references to the user's message. -func (a *Adapter) buildMessageWithImages(message string, imagePaths []string) string { - var sb strings.Builder - - // Add image references at the beginning - if len(imagePaths) == 1 { - sb.WriteString(fmt.Sprintf("I've attached an image file that you should read and analyze: %s\n\n", imagePaths[0])) - } else { - sb.WriteString("I've attached image files that you should read and analyze:\n") - for _, path := range imagePaths { - sb.WriteString(fmt.Sprintf("- %s\n", path)) - } - sb.WriteString("\n") - } - - // Add the original message - sb.WriteString(message) - - return sb.String() -} - -// Cancel interrupts the current operation. -func (a *Adapter) Cancel(ctx context.Context) error { - a.mu.RLock() - client := a.client - a.mu.RUnlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - a.logger.Info("cancelling operation") - - // Send interrupt control request - return client.SendControlRequest(&claudecode.SDKControlRequest{ - Type: claudecode.MessageTypeControlRequest, - RequestID: uuid.New().String(), - Request: claudecode.SDKControlRequestBody{ - Subtype: claudecode.SubtypeInterrupt, - }, - }) + return nil } // Updates returns the channel for agent events. @@ -532,34 +204,6 @@ func (a *Adapter) SetStderrProvider(provider StderrProvider) { a.stderrProvider = provider } -// Close releases resources held by the adapter. -func (a *Adapter) Close() error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.closed { - return nil - } - a.closed = true - - a.logger.Info("closing stream-json adapter") - - // Cancel the context to stop the read loop - if a.cancel != nil { - a.cancel() - } - - // Stop the client - if a.client != nil { - a.client.Stop() - } - - // Close update channel - close(a.updatesCh) - - return nil -} - // RequiresProcessKill returns false because Claude Code agents exit when stdin is closed. func (a *Adapter) RequiresProcessKill() bool { return false @@ -575,157 +219,6 @@ func (a *Adapter) sendUpdate(update AgentEvent) { } } -// handleControlRequest processes control requests (permission requests) from the agent. -func (a *Adapter) handleControlRequest(requestID string, req *claudecode.ControlRequest) { - a.logger.Info("received control request", - zap.String("request_id", requestID), - zap.String("subtype", req.Subtype), - zap.String("tool_name", req.ToolName)) - - switch req.Subtype { - case claudecode.SubtypeCanUseTool: - a.handleToolPermission(requestID, req) - case claudecode.SubtypeHookCallback: - a.handleHookCallback(requestID, req) - default: - a.logger.Warn("unhandled control request subtype", - zap.String("subtype", req.Subtype)) - // Send error response - if err := a.client.SendControlResponse(&claudecode.ControlResponseMessage{ - Type: claudecode.MessageTypeControlResponse, - RequestID: requestID, - Response: &claudecode.ControlResponse{ - Subtype: "error", - Error: fmt.Sprintf("unhandled subtype: %s", req.Subtype), - }, - }); err != nil { - a.logger.Warn("failed to send error response", zap.Error(err)) - } - } -} - -// handleToolPermission processes can_use_tool permission requests. -func (a *Adapter) handleToolPermission(requestID string, req *claudecode.ControlRequest) { - a.mu.RLock() - handler := a.permissionHandler - sessionID := a.sessionID - a.mu.RUnlock() - - // Determine action type based on tool name - actionType := types.ActionTypeOther - switch req.ToolName { - case claudecode.ToolBash: - actionType = types.ActionTypeCommand - case claudecode.ToolWrite, claudecode.ToolEdit, claudecode.ToolNotebookEdit: - actionType = types.ActionTypeFileWrite - case claudecode.ToolRead, claudecode.ToolGlob, claudecode.ToolGrep: - actionType = types.ActionTypeFileRead - case claudecode.ToolWebFetch, claudecode.ToolWebSearch: - actionType = types.ActionTypeNetwork - } - - // Build title from tool name and key input - title := req.ToolName - if cmd, ok := req.Input["command"].(string); ok && req.ToolName == claudecode.ToolBash { - title = cmd - } else if path, ok := req.Input["file_path"].(string); ok { - title = fmt.Sprintf("%s: %s", req.ToolName, path) - } - - // Build permission options - options := []PermissionOption{ - {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, - {OptionID: "allowAlways", Name: "Allow Always", Kind: "allow_always"}, - {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, - } - - // Build permission request with Claude Code's requestID. - // The handler (process manager's handlePermissionRequest) will: - // 1. Send the permission_request notification to the frontend - // 2. Block waiting for user response - // 3. Return the response - // We pass PendingID so the handler uses Claude Code's requestID - // instead of generating a new one - this ensures the frontend and backend - // use the same ID for response lookup. - permReq := &PermissionRequest{ - SessionID: sessionID, - ToolCallID: req.ToolUseID, - Title: title, - Options: options, - ActionType: actionType, - ActionDetails: req.Input, - PendingID: requestID, // Use Claude Code's requestID so response lookup works - } - - // If no handler, auto-allow - if handler == nil { - a.logger.Debug("auto-allowing tool (no handler)", - zap.String("tool", req.ToolName)) - a.sendPermissionResponse(requestID, claudecode.BehaviorAllow) - return - } - - // Call permission handler (blocking) - it will send the notification - ctx := context.Background() - resp, err := handler(ctx, permReq) - if err != nil { - a.logger.Error("permission handler error", zap.Error(err)) - a.sendPermissionResponse(requestID, claudecode.BehaviorDeny) - return - } - - // Map response to behavior - behavior := claudecode.BehaviorAllow - if resp.Cancelled { - behavior = claudecode.BehaviorDeny - } else { - switch resp.OptionID { - case "allow", "allowAlways", "approve", "approveAlways": - behavior = claudecode.BehaviorAllow - case "deny", "reject", "decline": - behavior = claudecode.BehaviorDeny - } - } - - a.sendPermissionResponse(requestID, behavior) -} - -// sendPermissionResponse sends a permission response to the agent. -func (a *Adapter) sendPermissionResponse(requestID string, behavior string) { - resp := &claudecode.ControlResponseMessage{ - Type: claudecode.MessageTypeControlResponse, - RequestID: requestID, - Response: &claudecode.ControlResponse{ - Subtype: "success", - Result: &claudecode.PermissionResult{ - Behavior: behavior, - }, - }, - } - - if err := a.client.SendControlResponse(resp); err != nil { - a.logger.Warn("failed to send permission response", zap.Error(err)) - } -} - -// handleHookCallback processes hook callback requests. -func (a *Adapter) handleHookCallback(requestID string, req *claudecode.ControlRequest) { - a.logger.Info("received hook callback", - zap.String("request_id", requestID), - zap.String("hook_name", req.HookName)) - - // For now, acknowledge hook callbacks with success - if err := a.client.SendControlResponse(&claudecode.ControlResponseMessage{ - Type: claudecode.MessageTypeControlResponse, - RequestID: requestID, - Response: &claudecode.ControlResponse{ - Subtype: "success", - }, - }); err != nil { - a.logger.Warn("failed to send hook callback response", zap.Error(err)) - } -} - // handleMessage processes streaming messages from the agent. func (a *Adapter) handleMessage(msg *claudecode.CLIMessage) { // Log raw event for debugging @@ -755,432 +248,3 @@ func (a *Adapter) handleMessage(msg *claudecode.CLIMessage) { a.logger.Debug("unhandled message type", zap.String("type", msg.Type)) } } - -// handleSystemMessage processes system init messages. -// Note: System messages are session initialization, NOT turn completion. -// Turn completion is signaled by result messages. -func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) { - a.logger.Info("received system message", - zap.String("session_id", msg.SessionID), - zap.String("status", msg.SessionStatus), - zap.Int("slash_commands_count", len(msg.SlashCommands))) - - // Update session ID if provided - a.mu.Lock() - if msg.SessionID != "" { - a.sessionID = msg.SessionID - } - alreadySent := a.sessionStatusSent - a.sessionStatusSent = true - a.mu.Unlock() - - // Emit available commands if present (do this on every system message, - // not just the first, in case commands change) - // Note: System message slash_commands is just an array of names (strings), - // so we only have the name, not description. The initialize response has full details. - if len(msg.SlashCommands) > 0 { - commands := make([]streams.AvailableCommand, len(msg.SlashCommands)) - for i, name := range msg.SlashCommands { - commands[i] = streams.AvailableCommand{ - Name: name, - Description: "", // System message only has names, not descriptions - } - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeAvailableCommands, - SessionID: msg.SessionID, - AvailableCommands: commands, - }) - } - - // Only send session status event once per session (on first prompt) - // The agent sends system messages on every prompt, but we only want to - // show "New session started" or "Session resumed" once - if alreadySent { - return - } - - // Send session status event (NOT complete - that's only for result messages) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeSessionStatus, - SessionID: msg.SessionID, - Data: map[string]any{ - "session_status": msg.SessionStatus, - "init": true, - }, - }) -} - -// handleAssistantMessage processes assistant messages (text, thinking, tool calls). -func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { - if msg.Message == nil { - return - } - - // Extract parent tool use ID for subagent nesting - parentToolUseID := msg.ParentToolUseID - - // Get content blocks (may be nil if content is a string) - contentBlocks := msg.Message.GetContentBlocks() - - // Log content block types for debugging - blockTypes := make([]string, 0, len(contentBlocks)) - for _, block := range contentBlocks { - blockTypes = append(blockTypes, block.Type) - } - a.logger.Debug("processing assistant message", - zap.Int("num_blocks", len(contentBlocks)), - zap.Strings("block_types", blockTypes), - zap.String("parent_tool_use_id", parentToolUseID)) - - // Update agent version and track main model name from model info - if msg.Message.Model != "" && a.agentInfo != nil { - a.agentInfo.Version = msg.Message.Model - - // Track the main model name for context window lookup - // Only set if not already set (first model we see is the main one) - a.mu.Lock() - if a.mainModelName == "" { - a.mainModelName = msg.Message.Model - a.logger.Debug("tracking main model", zap.String("model", msg.Message.Model)) - } - a.mu.Unlock() - } - - // Process content blocks - for _, block := range contentBlocks { - switch block.Type { - case "text": - if block.Text != "" { - // Mark that we've sent streaming text this turn - // This prevents duplicate content from result.text - a.mu.Lock() - a.streamingTextSentThisTurn = true - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: block.Text, - }) - } - - case "thinking": - if block.Thinking != "" { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeReasoning, - SessionID: sessionID, - OperationID: operationID, - ReasoningText: block.Thinking, - }) - } - - case "tool_use": - // Generate normalized payload using the normalizer - normalizedPayload := a.normalizer.NormalizeToolCall(block.Name, block.Input) - - // Detect specific tool operation type for logging - toolType := DetectStreamJSONToolType(block.Name) - - // Build a human-readable title for the tool call - toolTitle := block.Name - if cmd, ok := block.Input["command"].(string); ok && block.Name == claudecode.ToolBash { - toolTitle = cmd - } else if path, ok := block.Input["file_path"].(string); ok { - toolTitle = fmt.Sprintf("%s: %s", block.Name, path) - } - a.logger.Debug("tool_use block received", - zap.String("tool_call_id", block.ID), - zap.String("tool_name", block.Name), - zap.String("tool_type", toolType), - zap.String("title", toolTitle)) - - // Track this tool call as pending with its payload for result enrichment - a.mu.Lock() - a.pendingToolCalls[block.ID] = normalizedPayload - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: block.ID, - ParentToolCallID: parentToolUseID, - ToolName: block.Name, - ToolTitle: toolTitle, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) - - } - } - - // Calculate and emit token usage as context window event - if msg.Message.Usage != nil { - usage := msg.Message.Usage - - // Calculate total tokens used (including cache tokens) - contextUsed := usage.InputTokens + usage.OutputTokens + - usage.CacheCreationInputTokens + usage.CacheReadInputTokens - - // Update tracked token usage - a.mu.Lock() - a.contextTokensUsed = contextUsed - contextSize := a.mainModelContextWindow - a.mu.Unlock() - - remaining := contextSize - contextUsed - if remaining < 0 { - remaining = 0 - } - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeContextWindow, - SessionID: sessionID, - OperationID: operationID, - ContextWindowSize: contextSize, - ContextWindowUsed: contextUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, - }) - } -} - -// handleUserMessage processes user messages containing tool results or slash command output. -// Claude Code sends tool results back as user messages with tool_result content blocks. -// For slash commands, content may be a plain string wrapped in tags. -func (a *Adapter) handleUserMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { - if msg.Message == nil { - return - } - - // Check if content is a string (slash command output) - if contentStr := msg.Message.GetContentString(); contentStr != "" { - // Extract text from tags if present - text := contentStr - if strings.HasPrefix(text, "") && strings.HasSuffix(text, "") { - text = strings.TrimPrefix(text, "") - text = strings.TrimSuffix(text, "") - } - - if text != "" { - a.logger.Info("received user message with string content (slash command output)", - zap.String("session_id", sessionID), - zap.Int("content_length", len(text))) - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: text, - }) - } - return - } - - // Process content blocks looking for tool_result - contentBlocks := msg.Message.GetContentBlocks() - for _, block := range contentBlocks { - if block.Type != "tool_result" { - continue - } - - // Get and enrich the pending payload with result content - a.mu.Lock() - payload := a.pendingToolCalls[block.ToolUseID] - delete(a.pendingToolCalls, block.ToolUseID) - a.mu.Unlock() - - // Enrich payload with result content - if payload != nil && block.Content != "" { - a.normalizer.NormalizeToolResult(payload, block.Content) - } - - // If there's an error, set the error flag on the payload - // This ensures the frontend can display error messages properly - if payload != nil && block.IsError { - if payload.HttpRequest() != nil { - payload.HttpRequest().IsError = true - } - } - - // Determine status - status := "complete" - if block.IsError { - status = "error" - } - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: block.ToolUseID, - ToolStatus: status, - NormalizedPayload: payload, - }) - } -} - -// handleResultMessage processes result (completion) messages. -func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { - a.logger.Info("received result message", - zap.Bool("is_error", msg.IsError), - zap.Int("num_turns", msg.NumTurns)) - - // Update session ID from result if provided - if resultData := msg.GetResultData(); resultData != nil && resultData.SessionID != "" { - a.mu.Lock() - a.sessionID = resultData.SessionID - sessionID = resultData.SessionID - a.mu.Unlock() - } - - // Auto-complete any pending tool calls that didn't receive explicit tool_result - // This can happen if the tool result is not sent as a separate assistant message - a.mu.Lock() - pendingTools := make([]string, 0, len(a.pendingToolCalls)) - for toolID := range a.pendingToolCalls { - pendingTools = append(pendingTools, toolID) - } - a.pendingToolCalls = make(map[string]*streams.NormalizedPayload) // Clear pending - a.mu.Unlock() - - for _, toolID := range pendingTools { - a.logger.Debug("auto-completing pending tool call on result", - zap.String("tool_call_id", toolID)) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolID, - ToolStatus: "complete", - }) - } - - // Extract actual context window from model_usage if available - // This gives us the real context window size for the model - a.mu.Lock() - modelName := a.mainModelName - if msg.ModelUsage != nil && modelName != "" { - if modelStats, ok := msg.ModelUsage[modelName]; ok && modelStats.ContextWindow != nil { - a.mainModelContextWindow = *modelStats.ContextWindow - a.logger.Debug("updated context window from model_usage", - zap.String("model", modelName), - zap.Int64("context_window", a.mainModelContextWindow)) - } - } - contextSize := a.mainModelContextWindow - contextUsed := a.contextTokensUsed - a.mu.Unlock() - - // Emit final accurate context window event - if contextUsed > 0 { - remaining := contextSize - contextUsed - if remaining < 0 { - remaining = 0 - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeContextWindow, - SessionID: sessionID, - OperationID: operationID, - ContextWindowSize: contextSize, - ContextWindowUsed: contextUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, - }) - } - - // Check if text was already streamed this turn via assistant messages - a.mu.RLock() - textWasStreamed := a.streamingTextSentThisTurn - a.mu.RUnlock() - - // Only send result.text if NO text was streamed this turn. - // If text was already streamed via assistant messages, result.text is a duplicate. - // This prevents the same content from appearing twice in the conversation. - if !textWasStreamed { - var resultText string - if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { - resultText = resultData.Text - } else if resultStr := msg.GetResultString(); resultStr != "" { - resultText = resultStr - } - if resultText != "" { - a.logger.Debug("sending result text as message_chunk (no streaming text this turn)", - zap.Int("text_length", len(resultText))) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: resultText, - }) - } - } else { - a.logger.Debug("skipping result text (streaming text already sent this turn)") - } - - // Reset the streaming flag for next turn - a.mu.Lock() - a.streamingTextSentThisTurn = false - a.mu.Unlock() - - // Build error message from errors array if available - var errorMsg string - if msg.IsError && len(msg.Errors) > 0 { - errorMsg = strings.Join(msg.Errors, "; ") - } - - // Send completion event AFTER any result text chunk - completeData := map[string]any{ - "cost_usd": msg.CostUSD, - "duration_ms": msg.DurationMS, - "num_turns": msg.NumTurns, - "input_tokens": msg.TotalInputTokens, - "output_tokens": msg.TotalOutputTokens, - "is_error": msg.IsError, - } - if len(msg.Errors) > 0 { - completeData["errors"] = msg.Errors - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: sessionID, - OperationID: operationID, - Error: errorMsg, - Data: completeData, - }) - - // Signal completion - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - if resultCh != nil { - select { - case resultCh <- resultComplete{success: !msg.IsError, err: errorMsg}: - a.logger.Debug("signaled prompt completion") - default: - a.logger.Warn("result channel full, dropping signal") - } - } - - // Send error event if failed - if msg.IsError { - errMsg := "prompt failed" - // Use errors array first (most specific), then fallback to result string/data - if len(msg.Errors) > 0 { - errMsg = strings.Join(msg.Errors, "; ") - } else if errStr := msg.GetResultString(); errStr != "" { - errMsg = errStr - } else if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { - errMsg = resultData.Text - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: sessionID, - OperationID: operationID, - Error: errMsg, - }) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go index d713f71945..81468ba6a8 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go @@ -61,34 +61,70 @@ func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, res switch payload.Kind() { case streams.ToolKindReadFile: - if payload.ReadFile() != nil && resultStr != "" { - shared.NormalizeReadResult(payload.ReadFile(), resultStr) - } + n.normalizeReadFileResult(payload, resultStr) case streams.ToolKindCodeSearch: - if payload.CodeSearch() != nil && resultStr != "" { - shared.NormalizeCodeSearchResult(payload.CodeSearch(), resultStr) - } + n.normalizeCodeSearchResult(payload, resultStr) case streams.ToolKindModifyFile: - if payload.ModifyFile() != nil && resultStr != "" { - shared.NormalizeModifyResult(payload.ModifyFile(), resultStr) - } + n.normalizeModifyFileResult(payload, resultStr) case streams.ToolKindShellExec: - if payload.ShellExec() != nil { - shared.NormalizeShellResult(payload.ShellExec(), result) - } + n.normalizeShellExecResult(payload, result) case streams.ToolKindHttpRequest: - if payload.HttpRequest() != nil { - if r, ok := result.(string); ok { - payload.HttpRequest().Response = r - } - } + n.normalizeHttpRequestResult(payload, result) case streams.ToolKindGeneric: - if payload.Generic() != nil { - payload.Generic().Output = result - } + n.normalizeGenericResult(payload, result) } } +// normalizeReadFileResult populates ReadFile output. +func (n *Normalizer) normalizeReadFileResult(payload *streams.NormalizedPayload, resultStr string) { + if payload.ReadFile() == nil || resultStr == "" { + return + } + shared.NormalizeReadResult(payload.ReadFile(), resultStr) +} + +// normalizeCodeSearchResult populates CodeSearch output. +func (n *Normalizer) normalizeCodeSearchResult(payload *streams.NormalizedPayload, resultStr string) { + if payload.CodeSearch() == nil || resultStr == "" { + return + } + shared.NormalizeCodeSearchResult(payload.CodeSearch(), resultStr) +} + +// normalizeModifyFileResult populates ModifyFile output. +func (n *Normalizer) normalizeModifyFileResult(payload *streams.NormalizedPayload, resultStr string) { + if payload.ModifyFile() == nil || resultStr == "" { + return + } + shared.NormalizeModifyResult(payload.ModifyFile(), resultStr) +} + +// normalizeShellExecResult populates ShellExec output. +func (n *Normalizer) normalizeShellExecResult(payload *streams.NormalizedPayload, result any) { + if payload.ShellExec() == nil { + return + } + shared.NormalizeShellResult(payload.ShellExec(), result) +} + +// normalizeHttpRequestResult populates HttpRequest response. +func (n *Normalizer) normalizeHttpRequestResult(payload *streams.NormalizedPayload, result any) { + if payload.HttpRequest() == nil { + return + } + if r, ok := result.(string); ok { + payload.HttpRequest().Response = r + } +} + +// normalizeGenericResult populates Generic output. +func (n *Normalizer) normalizeGenericResult(payload *streams.NormalizedPayload, result any) { + if payload.Generic() == nil { + return + } + payload.Generic().Output = result +} + // normalizeEdit converts stream-json Edit/Write tool data. func (n *Normalizer) normalizeEdit(toolName string, args map[string]any) *streams.NormalizedPayload { filePath := shared.GetString(args, "file_path") diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go new file mode 100644 index 0000000000..785840c800 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go @@ -0,0 +1,134 @@ +package streamjson + +import ( + "context" + "encoding/json" + "time" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/pkg/claudecode" + "go.uber.org/zap" +) + +// PrepareEnvironment performs protocol-specific setup before the agent process starts. +// Stream-json protocol reads MCP configuration from settings files, but we handle MCP via kandev's +// built-in MCP server, so this is a no-op. +func (a *Adapter) PrepareEnvironment() (map[string]string, error) { + a.logger.Info("PrepareEnvironment called", + zap.Int("mcp_server_count", len(a.cfg.McpServers))) + // MCP configuration is handled externally or via CLI flags + return nil, nil +} + +// PrepareCommandArgs returns extra command-line arguments for the agent process. +// For stream-json (Claude Code), MCP configuration is passed via --mcp-config flag. +func (a *Adapter) PrepareCommandArgs() []string { + if len(a.cfg.McpServers) == 0 { + return nil + } + + // Build MCP configuration in Claude Code format + // Format: { "server-name": { "command": "...", "args": [...] } } + mcpConfig := make(map[string]interface{}) + for _, server := range a.cfg.McpServers { + serverDef := make(map[string]interface{}) + + // Handle different transport types + if server.Command != "" { + // stdio transport + serverDef["command"] = server.Command + if len(server.Args) > 0 { + serverDef["args"] = server.Args + } + } else if server.URL != "" { + // SSE/HTTP transport + serverDef["url"] = server.URL + if server.Type != "" { + serverDef["type"] = server.Type + } + } + + mcpConfig[server.Name] = serverDef + } + + // Wrap in mcpServers key (Claude Code expects this format) + wrappedConfig := map[string]interface{}{ + "mcpServers": mcpConfig, + } + + // Convert to JSON string + configJSON, err := json.Marshal(wrappedConfig) + if err != nil { + a.logger.Warn("failed to marshal MCP config, skipping", + zap.Error(err), + zap.Int("server_count", len(a.cfg.McpServers))) + return nil + } + + a.logger.Info("prepared MCP configuration for Claude Code", + zap.Int("server_count", len(a.cfg.McpServers)), + zap.String("config", string(configJSON))) + + // Return --mcp-config flag with JSON string + return []string{"--mcp-config", string(configJSON)} +} + +// Initialize establishes the stream-json connection with the agent subprocess. +func (a *Adapter) Initialize(ctx context.Context) error { + a.logger.Info("initializing stream-json adapter", + zap.String("workdir", a.cfg.WorkDir)) + + // Create Claude Code client + a.client = claudecode.NewClient(a.stdin, a.stdout, a.logger) + a.client.SetRequestHandler(a.handleControlRequest) + a.client.SetMessageHandler(a.handleMessage) + + // Start reading from stdout with the adapter's context + // Wait for the read loop to be ready before sending initialize + readyC := a.client.Start(a.ctx) + select { + case <-readyC: + a.logger.Info("read loop is ready") + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Second): + a.logger.Warn("timeout waiting for read loop to start") + } + + // Store agent info (version will be populated from first message) + a.agentInfo = &AgentInfo{ + Name: a.agentID, + Version: "unknown", + } + + // Send initialize control request to get slash commands + // This is required for streaming mode (input-format=stream-json) + initResp, err := a.client.Initialize(ctx, 60*time.Second) + if err != nil { + a.logger.Warn("failed to initialize (continuing anyway)", zap.Error(err)) + } else if initResp != nil && len(initResp.Commands) > 0 { + // Store available commands to emit after session is created + commands := make([]streams.AvailableCommand, len(initResp.Commands)) + for i, cmd := range initResp.Commands { + commands[i] = streams.AvailableCommand{ + Name: cmd.Name, + Description: cmd.Description, + } + } + a.mu.Lock() + a.pendingAvailableCommands = commands + a.mu.Unlock() + + a.logger.Info("received slash commands from initialize", + zap.Int("count", len(commands))) + } + + a.logger.Info("stream-json adapter initialized") + + return nil +} + +// GetAgentInfo returns information about the connected agent. +func (a *Adapter) GetAgentInfo() *AgentInfo { + return a.agentInfo +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go new file mode 100644 index 0000000000..e416634d12 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go @@ -0,0 +1,291 @@ +package streamjson + +import ( + "fmt" + "strings" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/pkg/claudecode" + "go.uber.org/zap" +) + +// handleSystemMessage processes system init messages. +// Note: System messages are session initialization, NOT turn completion. +// Turn completion is signaled by result messages. +func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) { + a.logger.Info("received system message", + zap.String("session_id", msg.SessionID), + zap.String("status", msg.SessionStatus), + zap.Int("slash_commands_count", len(msg.SlashCommands))) + + // Update session ID if provided + a.mu.Lock() + if msg.SessionID != "" { + a.sessionID = msg.SessionID + } + alreadySent := a.sessionStatusSent + a.sessionStatusSent = true + a.mu.Unlock() + + // Emit available commands if present (do this on every system message, + // not just the first, in case commands change) + // Note: System message slash_commands is just an array of names (strings), + // so we only have the name, not description. The initialize response has full details. + if len(msg.SlashCommands) > 0 { + commands := make([]streams.AvailableCommand, len(msg.SlashCommands)) + for i, name := range msg.SlashCommands { + commands[i] = streams.AvailableCommand{ + Name: name, + Description: "", // System message only has names, not descriptions + } + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeAvailableCommands, + SessionID: msg.SessionID, + AvailableCommands: commands, + }) + } + + // Only send session status event once per session (on first prompt) + // The agent sends system messages on every prompt, but we only want to + // show "New session started" or "Session resumed" once + if alreadySent { + return + } + + // Send session status event (NOT complete - that's only for result messages) + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeSessionStatus, + SessionID: msg.SessionID, + Data: map[string]any{ + "session_status": msg.SessionStatus, + "init": true, + }, + }) +} + +// updateMainModel tracks the first model seen as the main model name. +func (a *Adapter) updateMainModel(model string) { + if model == "" || a.agentInfo == nil { + return + } + a.agentInfo.Version = model + a.mu.Lock() + if a.mainModelName == "" { + a.mainModelName = model + a.logger.Debug("tracking main model", zap.String("model", model)) + } + a.mu.Unlock() +} + +// emitContextWindow emits a context window event. +func (a *Adapter) emitContextWindow(sessionID, operationID string, contextUsed, contextSize int64) { + remaining := contextSize - contextUsed + if remaining < 0 { + remaining = 0 + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeContextWindow, + SessionID: sessionID, + OperationID: operationID, + ContextWindowSize: contextSize, + ContextWindowUsed: contextUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, + }) +} + +// processTextBlock handles a text content block from an assistant message. +func (a *Adapter) processTextBlock(block claudecode.ContentBlock, sessionID, operationID string) { + if block.Text == "" { + return + } + // Mark that we've sent streaming text this turn + // This prevents duplicate content from result.text + a.mu.Lock() + a.streamingTextSentThisTurn = true + a.mu.Unlock() + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: block.Text, + }) +} + +// processToolUseBlock handles a tool_use content block from an assistant message. +func (a *Adapter) processToolUseBlock(block claudecode.ContentBlock, sessionID, operationID, parentToolUseID string) { + // Generate normalized payload using the normalizer + normalizedPayload := a.normalizer.NormalizeToolCall(block.Name, block.Input) + + // Detect specific tool operation type for logging + toolType := DetectStreamJSONToolType(block.Name) + + // Build a human-readable title for the tool call + toolTitle := block.Name + if cmd, ok := block.Input["command"].(string); ok && block.Name == claudecode.ToolBash { + toolTitle = cmd + } else if path, ok := block.Input["file_path"].(string); ok { + toolTitle = fmt.Sprintf("%s: %s", block.Name, path) + } + a.logger.Debug("tool_use block received", + zap.String("tool_call_id", block.ID), + zap.String("tool_name", block.Name), + zap.String("tool_type", toolType), + zap.String("title", toolTitle)) + + // Track this tool call as pending with its payload for result enrichment + a.mu.Lock() + a.pendingToolCalls[block.ID] = normalizedPayload + a.mu.Unlock() + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: block.ID, + ParentToolCallID: parentToolUseID, + ToolName: block.Name, + ToolTitle: toolTitle, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, + }) +} + +// handleAssistantMessage processes assistant messages (text, thinking, tool calls). +func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { + if msg.Message == nil { + return + } + + // Extract parent tool use ID for subagent nesting + parentToolUseID := msg.ParentToolUseID + + // Get content blocks (may be nil if content is a string) + contentBlocks := msg.Message.GetContentBlocks() + + // Log content block types for debugging + blockTypes := make([]string, 0, len(contentBlocks)) + for _, block := range contentBlocks { + blockTypes = append(blockTypes, block.Type) + } + a.logger.Debug("processing assistant message", + zap.Int("num_blocks", len(contentBlocks)), + zap.Strings("block_types", blockTypes), + zap.String("parent_tool_use_id", parentToolUseID)) + + // Update agent version and track main model name from model info + a.updateMainModel(msg.Message.Model) + + // Process content blocks + for _, block := range contentBlocks { + switch block.Type { + case "text": + a.processTextBlock(block, sessionID, operationID) + case "thinking": + if block.Thinking != "" { + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeReasoning, + SessionID: sessionID, + OperationID: operationID, + ReasoningText: block.Thinking, + }) + } + case "tool_use": + a.processToolUseBlock(block, sessionID, operationID, parentToolUseID) + } + } + + // Calculate and emit token usage as context window event + if msg.Message.Usage != nil { + usage := msg.Message.Usage + + // Calculate total tokens used (including cache tokens) + contextUsed := usage.InputTokens + usage.OutputTokens + + usage.CacheCreationInputTokens + usage.CacheReadInputTokens + + // Update tracked token usage + a.mu.Lock() + a.contextTokensUsed = contextUsed + contextSize := a.mainModelContextWindow + a.mu.Unlock() + + a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) + } +} + +// handleUserMessage processes user messages containing tool results or slash command output. +// Claude Code sends tool results back as user messages with tool_result content blocks. +// For slash commands, content may be a plain string wrapped in tags. +func (a *Adapter) handleUserMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { + if msg.Message == nil { + return + } + + // Check if content is a string (slash command output) + if contentStr := msg.Message.GetContentString(); contentStr != "" { + // Extract text from tags if present + text := contentStr + if strings.HasPrefix(text, "") && strings.HasSuffix(text, "") { + text = strings.TrimPrefix(text, "") + text = strings.TrimSuffix(text, "") + } + + if text != "" { + a.logger.Info("received user message with string content (slash command output)", + zap.String("session_id", sessionID), + zap.Int("content_length", len(text))) + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: text, + }) + } + return + } + + // Process content blocks looking for tool_result + contentBlocks := msg.Message.GetContentBlocks() + for _, block := range contentBlocks { + if block.Type != "tool_result" { + continue + } + + // Get and enrich the pending payload with result content + a.mu.Lock() + payload := a.pendingToolCalls[block.ToolUseID] + delete(a.pendingToolCalls, block.ToolUseID) + a.mu.Unlock() + + // Enrich payload with result content + if payload != nil && block.Content != "" { + a.normalizer.NormalizeToolResult(payload, block.Content) + } + + // If there's an error, set the error flag on the payload + // This ensures the frontend can display error messages properly + if payload != nil && block.IsError { + if payload.HttpRequest() != nil { + payload.HttpRequest().IsError = true + } + } + + // Determine status + status := "complete" + if block.IsError { + status = "error" + } + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: block.ToolUseID, + ToolStatus: status, + NormalizedPayload: payload, + }) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go new file mode 100644 index 0000000000..f0773c7282 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go @@ -0,0 +1,162 @@ +package streamjson + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/pkg/claudecode" + "go.uber.org/zap" +) + +// handleControlRequest processes control requests (permission requests) from the agent. +func (a *Adapter) handleControlRequest(requestID string, req *claudecode.ControlRequest) { + a.logger.Info("received control request", + zap.String("request_id", requestID), + zap.String("subtype", req.Subtype), + zap.String("tool_name", req.ToolName)) + + switch req.Subtype { + case claudecode.SubtypeCanUseTool: + a.handleToolPermission(requestID, req) + case claudecode.SubtypeHookCallback: + a.handleHookCallback(requestID, req) + default: + a.logger.Warn("unhandled control request subtype", + zap.String("subtype", req.Subtype)) + // Send error response + if err := a.client.SendControlResponse(&claudecode.ControlResponseMessage{ + Type: claudecode.MessageTypeControlResponse, + RequestID: requestID, + Response: &claudecode.ControlResponse{ + Subtype: "error", + Error: fmt.Sprintf("unhandled subtype: %s", req.Subtype), + }, + }); err != nil { + a.logger.Warn("failed to send error response", zap.Error(err)) + } + } +} + +// handleToolPermission processes can_use_tool permission requests. +func (a *Adapter) handleToolPermission(requestID string, req *claudecode.ControlRequest) { + a.mu.RLock() + handler := a.permissionHandler + sessionID := a.sessionID + a.mu.RUnlock() + + // Determine action type based on tool name + actionType := types.ActionTypeOther + switch req.ToolName { + case claudecode.ToolBash: + actionType = types.ActionTypeCommand + case claudecode.ToolWrite, claudecode.ToolEdit, claudecode.ToolNotebookEdit: + actionType = types.ActionTypeFileWrite + case claudecode.ToolRead, claudecode.ToolGlob, claudecode.ToolGrep: + actionType = types.ActionTypeFileRead + case claudecode.ToolWebFetch, claudecode.ToolWebSearch: + actionType = types.ActionTypeNetwork + } + + // Build title from tool name and key input + title := req.ToolName + if cmd, ok := req.Input["command"].(string); ok && req.ToolName == claudecode.ToolBash { + title = cmd + } else if path, ok := req.Input["file_path"].(string); ok { + title = fmt.Sprintf("%s: %s", req.ToolName, path) + } + + // Build permission options + options := []PermissionOption{ + {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, + {OptionID: "allowAlways", Name: "Allow Always", Kind: "allow_always"}, + {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, + } + + // Build permission request with Claude Code's requestID. + // The handler (process manager's handlePermissionRequest) will: + // 1. Send the permission_request notification to the frontend + // 2. Block waiting for user response + // 3. Return the response + // We pass PendingID so the handler uses Claude Code's requestID + // instead of generating a new one - this ensures the frontend and backend + // use the same ID for response lookup. + permReq := &PermissionRequest{ + SessionID: sessionID, + ToolCallID: req.ToolUseID, + Title: title, + Options: options, + ActionType: actionType, + ActionDetails: req.Input, + PendingID: requestID, // Use Claude Code's requestID so response lookup works + } + + // If no handler, auto-allow + if handler == nil { + a.logger.Debug("auto-allowing tool (no handler)", + zap.String("tool", req.ToolName)) + a.sendPermissionResponse(requestID, claudecode.BehaviorAllow) + return + } + + // Call permission handler (blocking) - it will send the notification + ctx := context.Background() + resp, err := handler(ctx, permReq) + if err != nil { + a.logger.Error("permission handler error", zap.Error(err)) + a.sendPermissionResponse(requestID, claudecode.BehaviorDeny) + return + } + + // Map response to behavior + behavior := claudecode.BehaviorAllow + if resp.Cancelled { + behavior = claudecode.BehaviorDeny + } else { + switch resp.OptionID { + case "allow", "allowAlways", "approve", "approveAlways": + behavior = claudecode.BehaviorAllow + case "deny", "reject", "decline": + behavior = claudecode.BehaviorDeny + } + } + + a.sendPermissionResponse(requestID, behavior) +} + +// sendPermissionResponse sends a permission response to the agent. +func (a *Adapter) sendPermissionResponse(requestID string, behavior string) { + resp := &claudecode.ControlResponseMessage{ + Type: claudecode.MessageTypeControlResponse, + RequestID: requestID, + Response: &claudecode.ControlResponse{ + Subtype: "success", + Result: &claudecode.PermissionResult{ + Behavior: behavior, + }, + }, + } + + if err := a.client.SendControlResponse(resp); err != nil { + a.logger.Warn("failed to send permission response", zap.Error(err)) + } +} + +// handleHookCallback processes hook callback requests. +func (a *Adapter) handleHookCallback(requestID string, req *claudecode.ControlRequest) { + a.logger.Info("received hook callback", + zap.String("request_id", requestID), + zap.String("hook_name", req.HookName)) + + // For now, acknowledge hook callbacks with success + if err := a.client.SendControlResponse(&claudecode.ControlResponseMessage{ + Type: claudecode.MessageTypeControlResponse, + RequestID: requestID, + Response: &claudecode.ControlResponse{ + Subtype: "success", + }, + }); err != nil { + a.logger.Warn("failed to send hook callback response", zap.Error(err)) + } +} + diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go new file mode 100644 index 0000000000..0390e6392d --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go @@ -0,0 +1,187 @@ +package streamjson + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/kandev/kandev/pkg/claudecode" + "go.uber.org/zap" +) + +// Prompt sends a prompt and waits for completion. +// Since stream-json protocol doesn't support multimodal content blocks, image attachments +// are saved to temp files in the workspace and the prompt is modified to reference them. +// Claude Code can then read these files with its Read tool. +func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { + a.mu.Lock() + client := a.client + sessionID := a.sessionID + operationID := uuid.New().String() + a.operationID = operationID + workDir := a.cfg.WorkDir + // Create channel to wait for result + a.resultCh = make(chan resultComplete, 1) + a.mu.Unlock() + + if client == nil { + return fmt.Errorf("adapter not initialized") + } + + // Handle image attachments by saving to temp files + finalMessage := message + if len(attachments) > 0 { + imagePaths, err := a.saveImageAttachments(workDir, attachments) + if err != nil { + a.logger.Warn("failed to save image attachments", zap.Error(err)) + } else if len(imagePaths) > 0 { + finalMessage = a.buildMessageWithImages(message, imagePaths) + } + } + + a.logger.Info("sending prompt", + zap.String("session_id", sessionID), + zap.String("operation_id", operationID), + zap.Int("attachments", len(attachments))) + + // Send user message + if err := client.SendUserMessage(finalMessage); err != nil { + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return fmt.Errorf("failed to send user message: %w", err) + } + + // Wait for result or context cancellation + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + select { + case <-ctx.Done(): + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return ctx.Err() + case result := <-resultCh: + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + if !result.success && result.err != "" { + return fmt.Errorf("prompt failed: %s", result.err) + } + a.logger.Info("prompt completed", + zap.String("operation_id", operationID), + zap.Bool("success", result.success)) + return nil + } +} + +// saveImageAttachments saves image attachments to temp files in the workspace. +// Returns the relative paths to the saved files. +func (a *Adapter) saveImageAttachments(workDir string, attachments []v1.MessageAttachment) ([]string, error) { + var imagePaths []string + + // Create temp directory for images + tempDir := filepath.Join(workDir, ".kandev", "temp", "images") + if err := os.MkdirAll(tempDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + for _, att := range attachments { + if att.Type != "image" { + continue + } + + // Decode base64 data + imageData, err := base64.StdEncoding.DecodeString(att.Data) + if err != nil { + a.logger.Warn("failed to decode image attachment", zap.Error(err)) + continue + } + + // Determine file extension from mime type + ext := ".png" // default + switch att.MimeType { + case "image/jpeg": + ext = ".jpg" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + case "image/png": + ext = ".png" + } + + // Generate unique filename + filename := fmt.Sprintf("image-%s%s", uuid.New().String()[:8], ext) + filePath := filepath.Join(tempDir, filename) + + // Write image to file + if err := os.WriteFile(filePath, imageData, 0644); err != nil { + a.logger.Warn("failed to write image file", zap.Error(err), zap.String("path", filePath)) + continue + } + + // Store relative path from workspace root + relPath, err := filepath.Rel(workDir, filePath) + if err != nil { + relPath = filePath // Fall back to absolute path + } + + imagePaths = append(imagePaths, relPath) + a.logger.Info("saved image attachment", + zap.String("path", relPath), + zap.Int("size", len(imageData))) + } + + return imagePaths, nil +} + +// buildMessageWithImages prepends image file references to the user's message. +func (a *Adapter) buildMessageWithImages(message string, imagePaths []string) string { + var sb strings.Builder + + // Add image references at the beginning + if len(imagePaths) == 1 { + sb.WriteString(fmt.Sprintf("I've attached an image file that you should read and analyze: %s\n\n", imagePaths[0])) + } else { + sb.WriteString("I've attached image files that you should read and analyze:\n") + for _, path := range imagePaths { + sb.WriteString(fmt.Sprintf("- %s\n", path)) + } + sb.WriteString("\n") + } + + // Add the original message + sb.WriteString(message) + + return sb.String() +} + +// Cancel interrupts the current operation. +func (a *Adapter) Cancel(ctx context.Context) error { + a.mu.RLock() + client := a.client + a.mu.RUnlock() + + if client == nil { + return fmt.Errorf("adapter not initialized") + } + + a.logger.Info("cancelling operation") + + // Send interrupt control request + return client.SendControlRequest(&claudecode.SDKControlRequest{ + Type: claudecode.MessageTypeControlRequest, + RequestID: uuid.New().String(), + Request: claudecode.SDKControlRequestBody{ + Subtype: claudecode.SubtypeInterrupt, + }, + }) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go new file mode 100644 index 0000000000..87f001b85e --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go @@ -0,0 +1,195 @@ +package streamjson + +import ( + "strings" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/pkg/claudecode" + "go.uber.org/zap" +) + +// updateContextWindowFromModelUsage updates context window from model_usage stats. +// Returns the updated contextUsed and contextSize. +func (a *Adapter) updateContextWindowFromModelUsage(msg *claudecode.CLIMessage) (contextUsed, contextSize int64) { + a.mu.Lock() + modelName := a.mainModelName + if msg.ModelUsage != nil && modelName != "" { + if modelStats, ok := msg.ModelUsage[modelName]; ok && modelStats.ContextWindow != nil { + a.mainModelContextWindow = *modelStats.ContextWindow + a.logger.Debug("updated context window from model_usage", + zap.String("model", modelName), + zap.Int64("context_window", a.mainModelContextWindow)) + } + } + contextSize = a.mainModelContextWindow + contextUsed = a.contextTokensUsed + a.mu.Unlock() + return contextUsed, contextSize +} + +// drainPendingToolCalls atomically removes and returns all pending tool call IDs. +func (a *Adapter) drainPendingToolCalls() []string { + a.mu.Lock() + pendingTools := make([]string, 0, len(a.pendingToolCalls)) + for toolID := range a.pendingToolCalls { + pendingTools = append(pendingTools, toolID) + } + a.pendingToolCalls = make(map[string]*streams.NormalizedPayload) // Clear pending + a.mu.Unlock() + return pendingTools +} + +// extractResultText extracts text from the result message (only if not already streamed). +func (a *Adapter) extractResultText(msg *claudecode.CLIMessage) string { + if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { + return resultData.Text + } + if resultStr := msg.GetResultString(); resultStr != "" { + return resultStr + } + return "" +} + +// extractResultErrorMsg extracts the error message from a result. +func (a *Adapter) extractResultErrorMsg(msg *claudecode.CLIMessage) string { + if !msg.IsError { + return "" + } + if len(msg.Errors) > 0 { + return strings.Join(msg.Errors, "; ") + } + return "" +} + +// signalResultCompletion sends the result to the waiting prompt goroutine. +func (a *Adapter) signalResultCompletion(success bool, errMsg string) { + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + if resultCh == nil { + return + } + select { + case resultCh <- resultComplete{success: success, err: errMsg}: + a.logger.Debug("signaled prompt completion") + default: + a.logger.Warn("result channel full, dropping signal") + } +} + +// handleResultMessage processes result (completion) messages. +func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { + a.logger.Info("received result message", + zap.Bool("is_error", msg.IsError), + zap.Int("num_turns", msg.NumTurns)) + + // Update session ID from result if provided + if resultData := msg.GetResultData(); resultData != nil && resultData.SessionID != "" { + a.mu.Lock() + a.sessionID = resultData.SessionID + sessionID = resultData.SessionID + a.mu.Unlock() + } + + // Auto-complete any pending tool calls that didn't receive explicit tool_result + // This can happen if the tool result is not sent as a separate assistant message + pendingTools := a.drainPendingToolCalls() + for _, toolID := range pendingTools { + a.logger.Debug("auto-completing pending tool call on result", + zap.String("tool_call_id", toolID)) + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolID, + ToolStatus: "complete", + }) + } + + // Extract actual context window from model_usage if available + contextUsed, contextSize := a.updateContextWindowFromModelUsage(msg) + + // Emit final accurate context window event + if contextUsed > 0 { + a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) + } + + // Check if text was already streamed this turn via assistant messages + a.mu.RLock() + textWasStreamed := a.streamingTextSentThisTurn + a.mu.RUnlock() + + // Only send result.text if NO text was streamed this turn. + // If text was already streamed via assistant messages, result.text is a duplicate. + // This prevents the same content from appearing twice in the conversation. + if !textWasStreamed { + if resultText := a.extractResultText(msg); resultText != "" { + a.logger.Debug("sending result text as message_chunk (no streaming text this turn)", + zap.Int("text_length", len(resultText))) + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: resultText, + }) + } + } else { + a.logger.Debug("skipping result text (streaming text already sent this turn)") + } + + // Reset the streaming flag for next turn + a.mu.Lock() + a.streamingTextSentThisTurn = false + a.mu.Unlock() + + // Build error message from errors array if available + errorMsg := a.extractResultErrorMsg(msg) + + // Send completion event AFTER any result text chunk + completeData := map[string]any{ + "cost_usd": msg.CostUSD, + "duration_ms": msg.DurationMS, + "num_turns": msg.NumTurns, + "input_tokens": msg.TotalInputTokens, + "output_tokens": msg.TotalOutputTokens, + "is_error": msg.IsError, + } + if len(msg.Errors) > 0 { + completeData["errors"] = msg.Errors + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeComplete, + SessionID: sessionID, + OperationID: operationID, + Error: errorMsg, + Data: completeData, + }) + + // Signal completion + a.signalResultCompletion(!msg.IsError, errorMsg) + + // Send error event if failed + if msg.IsError { + a.sendResultError(msg, sessionID, operationID) + } +} + +// sendResultError emits an error event with the best available error message. +func (a *Adapter) sendResultError(msg *claudecode.CLIMessage, sessionID, operationID string) { + errMsg := "prompt failed" + // Use errors array first (most specific), then fallback to result string/data + if len(msg.Errors) > 0 { + errMsg = strings.Join(msg.Errors, "; ") + } else if errStr := msg.GetResultString(); errStr != "" { + errMsg = errStr + } else if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { + errMsg = resultData.Text + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeError, + SessionID: sessionID, + OperationID: operationID, + Error: errMsg, + }) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_session.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_session.go new file mode 100644 index 0000000000..f520ca058f --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_session.go @@ -0,0 +1,64 @@ +package streamjson + +import ( + "context" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "go.uber.org/zap" +) + +// emitPendingCommands emits any pending available commands for the given session. +// Must be called after the mutex is unlocked. +func (a *Adapter) emitPendingCommands(sessionID string, commands []streams.AvailableCommand) { + if len(commands) == 0 { + return + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeAvailableCommands, + SessionID: sessionID, + AvailableCommands: commands, + }) + a.logger.Debug("emitted pending slash commands", + zap.String("session_id", sessionID), + zap.Int("count", len(commands))) +} + +// takePendingCommands atomically takes and clears pending commands. +// Must be called with the mutex held. +func (a *Adapter) takePendingCommands() []streams.AvailableCommand { + commands := a.pendingAvailableCommands + a.pendingAvailableCommands = nil + return commands +} + +// NewSession creates a new stream-json session. +// Note: Sessions are created implicitly with the first prompt. +// The mcpServers parameter is ignored as this protocol handles MCP separately. +func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { + a.mu.Lock() + sessionID := uuid.New().String() + a.sessionID = sessionID + pendingCommands := a.takePendingCommands() + a.mu.Unlock() + + a.logger.Info("created new session placeholder", zap.String("session_id", sessionID)) + a.emitPendingCommands(sessionID, pendingCommands) + + return sessionID, nil +} + +// LoadSession resumes an existing stream-json session. +// The session ID will be passed to the agent via --resume flag (handled by process manager). +func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { + a.mu.Lock() + a.sessionID = sessionID + pendingCommands := a.takePendingCommands() + a.mu.Unlock() + + a.logger.Info("loaded session", zap.String("session_id", sessionID)) + a.emitPendingCommands(sessionID, pendingCommands) + + return nil +} diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index 3a29950ffb..9f88d679ee 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/server/adapter" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/constants" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -130,106 +131,99 @@ func (s *Server) handleAgentStreamWS(c *gin.Context) { mcpRequestCh = s.mcpBackendClient.GetRequestChannel() } - // WaitGroup for cleanup var wg sync.WaitGroup - - // Read goroutine: reads MCP responses and agent operation requests from the backend wg.Add(1) - go func() { - defer wg.Done() - defer cancel() - for { - _, message, err := conn.ReadMessage() - if err != nil { - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - s.logger.Info("agent stream closed normally") - } else { - s.logger.Debug("agent stream read error", zap.Error(err)) - } - return - } - - // Parse as WebSocket message - var msg ws.Message - if err := json.Unmarshal(message, &msg); err != nil { - s.logger.Warn("failed to parse message", zap.Error(err)) - continue - } - - // Handle agent operation requests (type=request) - if msg.Type == ws.MessageTypeRequest { - go func(reqMsg ws.Message) { - resp := s.handleAgentStreamRequest(ctx, &reqMsg) - if resp != nil { - data, err := json.Marshal(resp) - if err != nil { - s.logger.Error("failed to marshal WS response", zap.Error(err)) - return - } - if err := writeMessage(data); err != nil { - s.logger.Debug("failed to write WS response", zap.Error(err)) - } - } - }(msg) - continue - } - - // Forward MCP response to the backend client - if s.mcpBackendClient != nil && (msg.Type == ws.MessageTypeResponse || msg.Type == ws.MessageTypeError) { - s.mcpBackendClient.HandleResponse(&msg) - } - } - }() - - // Write goroutine: sends agent events and MCP requests to the backend + go s.runAgentStreamReader(ctx, conn, writeMessage, cancel, &wg) wg.Add(1) - go func() { - defer wg.Done() - defer func() { - if err := conn.Close(); err != nil { - s.logger.Debug("failed to close agent stream websocket", zap.Error(err)) - } - }() - - for { - select { - case <-ctx.Done(): - return - case notification, ok := <-updatesCh: - if !ok { - return - } - data, err := json.Marshal(notification) - if err != nil { - s.logger.Error("failed to marshal notification", zap.Error(err)) - continue - } - if err := writeMessage(data); err != nil { - s.logger.Debug("failed to write notification", zap.Error(err)) - return - } - case mcpReq, ok := <-mcpRequestCh: - if !ok { - // MCP channel closed, continue without MCP - mcpRequestCh = nil - continue - } - data, err := json.Marshal(mcpReq) - if err != nil { - s.logger.Error("failed to marshal MCP request", zap.Error(err)) - continue - } - if err := writeMessage(data); err != nil { - s.logger.Debug("failed to write MCP request", zap.Error(err)) - return - } - } - } - }() - + go s.runAgentStreamWriter(ctx, conn, updatesCh, mcpRequestCh, writeMessage, &wg) wg.Wait() } +// runAgentStreamReader reads MCP responses and agent operation requests from the backend connection. +func (s *Server) runAgentStreamReader(ctx context.Context, conn *websocket.Conn, writeMessage func([]byte) error, cancel context.CancelFunc, wg *sync.WaitGroup) { + defer wg.Done() + defer cancel() + for { + _, message, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + s.logger.Info("agent stream closed normally") + } else { + s.logger.Debug("agent stream read error", zap.Error(err)) + } + return + } + var msg ws.Message + if err := json.Unmarshal(message, &msg); err != nil { + s.logger.Warn("failed to parse message", zap.Error(err)) + continue + } + if msg.Type == ws.MessageTypeRequest { + go func(reqMsg ws.Message) { + resp := s.handleAgentStreamRequest(ctx, &reqMsg) + if resp == nil { + return + } + data, err := json.Marshal(resp) + if err != nil { + s.logger.Error("failed to marshal WS response", zap.Error(err)) + return + } + if err := writeMessage(data); err != nil { + s.logger.Debug("failed to write WS response", zap.Error(err)) + } + }(msg) + continue + } + if s.mcpBackendClient != nil && (msg.Type == ws.MessageTypeResponse || msg.Type == ws.MessageTypeError) { + s.mcpBackendClient.HandleResponse(&msg) + } + } +} + +// runAgentStreamWriter sends agent events and MCP requests to the backend connection. +func (s *Server) runAgentStreamWriter(ctx context.Context, conn *websocket.Conn, updatesCh <-chan adapter.AgentEvent, mcpRequestCh <-chan *ws.Message, writeMessage func([]byte) error, wg *sync.WaitGroup) { + defer wg.Done() + defer func() { + if err := conn.Close(); err != nil { + s.logger.Debug("failed to close agent stream websocket", zap.Error(err)) + } + }() + for { + select { + case <-ctx.Done(): + return + case notification, ok := <-updatesCh: + if !ok { + return + } + data, err := json.Marshal(notification) + if err != nil { + s.logger.Error("failed to marshal notification", zap.Error(err)) + continue + } + if err := writeMessage(data); err != nil { + s.logger.Debug("failed to write notification", zap.Error(err)) + return + } + case mcpReq, ok := <-mcpRequestCh: + if !ok { + mcpRequestCh = nil + continue + } + data, err := json.Marshal(mcpReq) + if err != nil { + s.logger.Error("failed to marshal MCP request", zap.Error(err)) + continue + } + if err := writeMessage(data); err != nil { + s.logger.Debug("failed to write MCP request", zap.Error(err)) + return + } + } + } +} + // handleAgentStreamRequest dispatches agent operation requests received on the WebSocket stream. func (s *Server) handleAgentStreamRequest(ctx context.Context, msg *ws.Message) *ws.Message { switch msg.Action { diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index a7b644c941..9f66b24911 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -2,8 +2,10 @@ package api import ( "encoding/json" + "io" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "go.uber.org/zap" @@ -52,40 +54,11 @@ func (s *Server) handleWorkspaceStreamWS(c *gin.Context) { defer close(done) // Handle incoming messages (shell_input, shell_resize, ping) in a goroutine - go func() { - for { - select { - case <-done: - return - default: - var msg types.WorkspaceStreamMessage - if err := conn.ReadJSON(&msg); err != nil { - s.logger.Debug("workspace stream WebSocket read error", zap.Error(err)) - return - } - - switch msg.Type { - case types.WorkspaceMessageTypeShellInput: - if shell != nil { - if _, err := shell.Write([]byte(msg.Data)); err != nil { - s.logger.Debug("shell write error", zap.Error(err)) - } - } - case types.WorkspaceMessageTypeShellResize: - // Resize is currently not implemented in the shell session - // TODO: Implement shell.Resize(cols, rows) when needed - s.logger.Debug("shell resize requested", zap.Int("cols", msg.Cols), zap.Int("rows", msg.Rows)) - case types.WorkspaceMessageTypePing: - // Respond with pong - pongMsg := types.NewWorkspacePong() - if err := conn.WriteJSON(pongMsg); err != nil { - s.logger.Debug("workspace stream pong write error", zap.Error(err)) - return - } - } - } - } - }() + var shellWriter io.Writer + if shell != nil { + shellWriter = shell + } + go s.handleWorkspaceStreamInput(conn, shellWriter, done) // Forward all workspace events to WebSocket for { @@ -122,7 +95,7 @@ func (s *Server) handleFileTree(c *gin.Context) { depth := 1 if d := c.Query("depth"); d != "" { if _, err := json.Number(d).Int64(); err == nil { - depth = int(mustParseInt(d)) + depth = mustParseInt(d) } } @@ -260,6 +233,37 @@ func (s *Server) handleFileDelete(c *gin.Context) { }) } +func (s *Server) handleWorkspaceStreamInput(conn *websocket.Conn, shell io.Writer, done <-chan struct{}) { + for { + select { + case <-done: + return + default: + var msg types.WorkspaceStreamMessage + if err := conn.ReadJSON(&msg); err != nil { + s.logger.Debug("workspace stream WebSocket read error", zap.Error(err)) + return + } + switch msg.Type { + case types.WorkspaceMessageTypeShellInput: + if shell != nil { + if _, err := shell.Write([]byte(msg.Data)); err != nil { + s.logger.Debug("shell write error", zap.Error(err)) + } + } + case types.WorkspaceMessageTypeShellResize: + s.logger.Debug("shell resize requested", zap.Int("cols", msg.Cols), zap.Int("rows", msg.Rows)) + case types.WorkspaceMessageTypePing: + pongMsg := types.NewWorkspacePong() + if err := conn.WriteJSON(pongMsg); err != nil { + s.logger.Debug("workspace stream pong write error", zap.Error(err)) + return + } + } + } + } +} + // mustParseInt parses a string to int, returns 0 on error func mustParseInt(s string) int { var n int diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 197f58c879..f4e5e47ea1 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -182,36 +182,7 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes, } - // Apply overrides if provided - if overrides != nil { - if overrides.Protocol != "" { - cfg.Protocol = overrides.Protocol - } - if overrides.AgentCommand != "" { - cfg.AgentCommand = overrides.AgentCommand - } - if overrides.WorkDir != "" { - cfg.WorkDir = overrides.WorkDir - } - if overrides.AutoStart != nil { - cfg.AutoStart = *overrides.AutoStart - } - if overrides.Env != nil { - cfg.AgentEnv = overrides.Env - } - if overrides.ApprovalPolicy != "" { - cfg.ApprovalPolicy = overrides.ApprovalPolicy - } - if overrides.AgentType != "" { - cfg.AgentType = overrides.AgentType - } - if len(overrides.McpServers) > 0 { - cfg.McpServers = overrides.McpServers - } - if overrides.SessionID != "" { - cfg.SessionID = overrides.SessionID - } - } + applyOverrides(cfg, overrides) // Inject local kandev MCP server for MCP tunneling through the agent stream // This ensures the kandev MCP server is available for protocols that read MCP config @@ -232,6 +203,40 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst return cfg } +// applyOverrides applies non-zero fields from overrides to cfg. +func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) { + if overrides == nil { + return + } + if overrides.Protocol != "" { + cfg.Protocol = overrides.Protocol + } + if overrides.AgentCommand != "" { + cfg.AgentCommand = overrides.AgentCommand + } + if overrides.WorkDir != "" { + cfg.WorkDir = overrides.WorkDir + } + if overrides.AutoStart != nil { + cfg.AutoStart = *overrides.AutoStart + } + if overrides.Env != nil { + cfg.AgentEnv = overrides.Env + } + if overrides.ApprovalPolicy != "" { + cfg.ApprovalPolicy = overrides.ApprovalPolicy + } + if overrides.AgentType != "" { + cfg.AgentType = overrides.AgentType + } + if len(overrides.McpServers) > 0 { + cfg.McpServers = overrides.McpServers + } + if overrides.SessionID != "" { + cfg.SessionID = overrides.SessionID + } +} + // InstanceOverrides allows overriding default values when creating an instance type InstanceOverrides struct { Protocol agent.Protocol diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index 59495bc5bb..ea70688ea4 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -56,74 +56,22 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea m.mu.Lock() defer m.mu.Unlock() - // Generate ID if not provided id := req.ID if id == "" { id = uuid.New().String() } - - // Check if ID already exists if _, exists := m.instances[id]; exists { return nil, fmt.Errorf("instance with ID %s already exists", id) } - // 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) + port, listener, err := m.allocatePortAndListener(id) + if err != nil { + return nil, err } - // Determine agent command, adding workspace flag if needed - agentCmd := req.AgentCommand - if agentCmd == "" { - agentCmd = m.config.Defaults.AgentCommand - } - if req.WorkspacePath != "" && req.WorkspaceFlag != "" { - if !strings.Contains(agentCmd, req.WorkspaceFlag) { - agentCmd = agentCmd + " " + req.WorkspaceFlag + " " + req.WorkspacePath - } - } - - // Build overrides from request + agentCmd := m.resolveAgentCommand(req) autoStart := req.AutoStart - - // Convert MCP server configs - var mcpServers []config.McpServerConfig - for _, mcp := range req.McpServers { - mcpServers = append(mcpServers, config.McpServerConfig{ - Name: mcp.Name, - URL: mcp.URL, - Type: mcp.Type, - Command: mcp.Command, - Args: mcp.Args, - }) - } + mcpServers := buildMcpServerConfigs(req.McpServers) m.logger.Info("CreateInstance: received request", zap.String("req_protocol", req.Protocol), @@ -156,32 +104,8 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea // 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 { - handler = m.serverFactory(instanceCfg, procMgr, m.logger) - } else { - // Default to a simple handler if no factory is set - handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - if _, err := w.Write([]byte("server factory not configured")); err != nil { - m.logger.Debug("failed to write default response", zap.Error(err)) - } - }) - } - - // Create HTTP server - httpServer := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: handler, - } - - // Start HTTP server in background - go func() { - if err := httpServer.Serve(listener); err != nil && err != http.ErrServerClosed { - m.logger.Error("instance server error", zap.String("instance_id", id), zap.Error(err)) - } - }() + handler := m.buildHTTPHandler(instanceCfg, procMgr) + httpServer := m.startHTTPServer(port, listener, handler, id) // Create and store instance inst := &Instance{ @@ -208,6 +132,87 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea }, nil } +// allocatePortAndListener allocates a free port and binds a TCP listener to it. +func (m *Manager) allocatePortAndListener(id string) (int, net.Listener, error) { + 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 0, nil, fmt.Errorf("failed to allocate port: %w", err) + } + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", allocated)) + 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 0, nil, fmt.Errorf("failed to bind instance port %d: %w", allocated, err) + } + return allocated, ln, nil + } + return 0, nil, fmt.Errorf("failed to allocate an available port for instance %s", id) +} + +// resolveAgentCommand returns the effective agent command for a create request. +func (m *Manager) resolveAgentCommand(req *CreateRequest) string { + agentCmd := req.AgentCommand + if agentCmd == "" { + agentCmd = m.config.Defaults.AgentCommand + } + if req.WorkspacePath != "" && req.WorkspaceFlag != "" && !strings.Contains(agentCmd, req.WorkspaceFlag) { + agentCmd = agentCmd + " " + req.WorkspaceFlag + " " + req.WorkspacePath + } + return agentCmd +} + +// buildMcpServerConfigs converts instance McpServerConfig entries to config.McpServerConfig. +func buildMcpServerConfigs(mcpServers []McpServerConfig) []config.McpServerConfig { + result := make([]config.McpServerConfig, 0, len(mcpServers)) + for _, mcp := range mcpServers { + result = append(result, config.McpServerConfig{ + Name: mcp.Name, + URL: mcp.URL, + Type: mcp.Type, + Command: mcp.Command, + Args: mcp.Args, + }) + } + return result +} + +// buildHTTPHandler creates the HTTP handler for an instance. +func (m *Manager) buildHTTPHandler(instanceCfg *config.InstanceConfig, procMgr *process.Manager) http.Handler { + if m.serverFactory != nil { + return m.serverFactory(instanceCfg, procMgr, m.logger) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + if _, err := w.Write([]byte("server factory not configured")); err != nil { + m.logger.Debug("failed to write default response", zap.Error(err)) + } + }) +} + +// startHTTPServer creates and starts an HTTP server on the given listener. +func (m *Manager) startHTTPServer(port int, listener net.Listener, handler http.Handler, id string) *http.Server { + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: handler, + } + go func() { + if err := httpServer.Serve(listener); err != nil && err != http.ErrServerClosed { + m.logger.Error("instance server error", + zap.String("instance_id", id), + zap.Error(err)) + } + }() + return httpServer +} + // GetInstance returns an instance by ID. func (m *Manager) GetInstance(id string) (*Instance, bool) { m.mu.RLock() diff --git a/apps/backend/internal/agentctl/server/mcp/handlers.go b/apps/backend/internal/agentctl/server/mcp/handlers.go index b02d39e22a..5f615a6da9 100644 --- a/apps/backend/internal/agentctl/server/mcp/handlers.go +++ b/apps/backend/internal/agentctl/server/mcp/handlers.go @@ -150,56 +150,19 @@ func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { if err != nil { return mcp.NewToolResultError("prompt is required"), nil } - args := req.GetArguments() - optionsRaw, ok := args["options"] - if !ok { - return mcp.NewToolResultError("options is required"), nil - } - // Parse options - must be array of objects with label and description - optionsJSON, err := json.Marshal(optionsRaw) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to parse options: %v", err)), nil - } - - var options []map[string]interface{} - if err := json.Unmarshal(optionsJSON, &options); err != nil { - return mcp.NewToolResultError("options must be an array of objects with 'label' and 'description' fields. Example: [{\"label\": \"Option A\", \"description\": \"Description of option A\"}]"), nil - } - - // Validate options - if len(options) < 2 { - return mcp.NewToolResultError("options must contain at least 2 choices"), nil - } - if len(options) > 6 { - return mcp.NewToolResultError("options must contain at most 6 choices"), nil - } - - for i, opt := range options { - label, hasLabel := opt["label"].(string) - if !hasLabel || label == "" { - return mcp.NewToolResultError(fmt.Sprintf("option %d is missing required 'label' field (1-5 words describing the choice)", i+1)), nil - } - description, hasDesc := opt["description"].(string) - if !hasDesc || description == "" { - return mcp.NewToolResultError(fmt.Sprintf("option %d is missing required 'description' field (explanation of what this option means)", i+1)), nil - } - // Generate option_id if not provided - if _, hasID := opt["option_id"].(string); !hasID { - opt["option_id"] = fmt.Sprintf("opt_%d", i+1) - } + options, errResult := parseQuestionOptions(req) + if errResult != nil { + return errResult, nil } questionCtx := req.GetString("context", "") - - // Build the question object in the format expected by the backend question := map[string]interface{}{ "id": "q1", "title": "Question", "prompt": prompt, "options": options, } - payload := map[string]interface{}{ "session_id": s.sessionID, "question": question, @@ -215,27 +178,92 @@ func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { return mcp.NewToolResultError(err.Error()), nil } - // Extract the answer from the response - if answer, ok := result["answer"]; ok { - if answerMap, ok := answer.(map[string]interface{}); ok { - if selectedOptions, ok := answerMap["selected_options"].([]interface{}); ok && len(selectedOptions) > 0 { - return mcp.NewToolResultText(fmt.Sprintf("User selected: %v", selectedOptions[0])), nil - } - if customText, ok := answerMap["custom_text"].(string); ok && customText != "" { - return mcp.NewToolResultText(fmt.Sprintf("User answered: %s", customText)), nil - } - } - } - if rejected, ok := result["rejected"].(bool); ok && rejected { - reason, _ := result["reject_reason"].(string) - return mcp.NewToolResultText(fmt.Sprintf("User rejected the question: %s", reason)), nil - } - - data, _ := json.MarshalIndent(result, "", " ") - return mcp.NewToolResultText(string(data)), nil + return extractQuestionAnswer(result), nil } } +// parseQuestionOptions extracts and validates the "options" argument from the request. +// Returns (options, nil) on success or (nil, *mcp.CallToolResult) on validation failure. +func parseQuestionOptions(req mcp.CallToolRequest) ([]map[string]interface{}, *mcp.CallToolResult) { + args := req.GetArguments() + optionsRaw, ok := args["options"] + if !ok { + return nil, mcp.NewToolResultError("options is required") + } + + optionsJSON, err := json.Marshal(optionsRaw) + if err != nil { + return nil, mcp.NewToolResultError(fmt.Sprintf("failed to parse options: %v", err)) + } + + var options []map[string]interface{} + if err := json.Unmarshal(optionsJSON, &options); err != nil { + return nil, mcp.NewToolResultError("options must be an array of objects with 'label' and 'description' fields. Example: [{\"label\": \"Option A\", \"description\": \"Description of option A\"}]") + } + + if len(options) < 2 { + return nil, mcp.NewToolResultError("options must contain at least 2 choices") + } + if len(options) > 6 { + return nil, mcp.NewToolResultError("options must contain at most 6 choices") + } + + if errResult := validateAndNormalizeOptions(options); errResult != nil { + return nil, errResult + } + + return options, nil +} + +// validateAndNormalizeOptions checks each option for required fields and assigns a default option_id. +func validateAndNormalizeOptions(options []map[string]interface{}) *mcp.CallToolResult { + for i, opt := range options { + label, hasLabel := opt["label"].(string) + if !hasLabel || label == "" { + return mcp.NewToolResultError(fmt.Sprintf("option %d is missing required 'label' field (1-5 words describing the choice)", i+1)) + } + description, hasDesc := opt["description"].(string) + if !hasDesc || description == "" { + return mcp.NewToolResultError(fmt.Sprintf("option %d is missing required 'description' field (explanation of what this option means)", i+1)) + } + // Generate option_id if not provided + if _, hasID := opt["option_id"].(string); !hasID { + opt["option_id"] = fmt.Sprintf("opt_%d", i+1) + } + } + return nil +} + +// extractQuestionAnswer converts the backend question response into an MCP tool result. +func extractQuestionAnswer(result map[string]interface{}) *mcp.CallToolResult { + if answer, ok := result["answer"]; ok { + if res := extractAnswerMap(answer); res != nil { + return res + } + } + if rejected, ok := result["rejected"].(bool); ok && rejected { + reason, _ := result["reject_reason"].(string) + return mcp.NewToolResultText(fmt.Sprintf("User rejected the question: %s", reason)) + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)) +} + +// extractAnswerMap inspects an answer map for selected options or custom text. +func extractAnswerMap(answer interface{}) *mcp.CallToolResult { + answerMap, ok := answer.(map[string]interface{}) + if !ok { + return nil + } + if selectedOptions, ok := answerMap["selected_options"].([]interface{}); ok && len(selectedOptions) > 0 { + return mcp.NewToolResultText(fmt.Sprintf("User selected: %v", selectedOptions[0])) + } + if customText, ok := answerMap["custom_text"].(string); ok && customText != "" { + return mcp.NewToolResultText(fmt.Sprintf("User answered: %s", customText)) + } + return nil +} + func (s *Server) createTaskPlanHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { taskID, err := req.RequireString("task_id") diff --git a/apps/backend/internal/agentctl/server/mcp/server.go b/apps/backend/internal/agentctl/server/mcp/server.go index ed2b95e4f3..fd6f6b4614 100644 --- a/apps/backend/internal/agentctl/server/mcp/server.go +++ b/apps/backend/internal/agentctl/server/mcp/server.go @@ -144,7 +144,8 @@ func (s *Server) wrapHandler(toolName string, handler server.ToolHandlerFunc) se result, err := handler(ctx, req) duration := time.Since(start) - if err != nil { + switch { + case err != nil: s.logger.Debug("MCP tool error", zap.String("tool", toolName), zap.Duration("duration", duration), @@ -156,7 +157,7 @@ func (s *Server) wrapHandler(toolName string, handler server.ToolHandlerFunc) se zap.Duration("duration", duration), zap.Error(err)) } - } else if result != nil && result.IsError { + case result != nil && result.IsError: s.logger.Debug("MCP tool returned error", zap.String("tool", toolName), zap.Duration("duration", duration), @@ -168,7 +169,7 @@ func (s *Server) wrapHandler(toolName string, handler server.ToolHandlerFunc) se zap.Duration("duration", duration), zap.Any("result", result.Content)) } - } else { + default: s.logger.Debug("MCP tool success", zap.String("tool", toolName), zap.Duration("duration", duration)) @@ -186,6 +187,13 @@ func (s *Server) wrapHandler(toolName string, handler server.ToolHandlerFunc) se // registerTools registers all MCP tools. func (s *Server) registerTools() { + s.registerKanbanTools() + s.registerInteractionTools() + s.registerPlanTools() + s.logger.Info("registered MCP tools", zap.Int("count", 11)) +} + +func (s *Server) registerKanbanTools() { // Use NewToolWithRawSchema for parameter-less tools to ensure the schema // includes "properties": {}. The default ToolInputSchema type in mcp-go uses // omitempty which drops empty properties maps, causing OpenAI API validation @@ -197,7 +205,6 @@ func (s *Server) registerTools() { ), s.wrapHandler("list_workspaces", s.listWorkspacesHandler()), ) - s.mcpServer.AddTool( mcp.NewTool("list_boards", mcp.WithDescription("List all boards in a workspace."), @@ -205,7 +212,6 @@ func (s *Server) registerTools() { ), s.wrapHandler("list_boards", s.listBoardsHandler()), ) - s.mcpServer.AddTool( mcp.NewTool("list_workflow_steps", mcp.WithDescription("List all workflow steps in a board."), @@ -213,7 +219,6 @@ func (s *Server) registerTools() { ), s.wrapHandler("list_workflow_steps", s.listWorkflowStepsHandler()), ) - s.mcpServer.AddTool( mcp.NewTool("list_tasks", mcp.WithDescription("List all tasks on a board."), @@ -221,7 +226,6 @@ func (s *Server) registerTools() { ), s.wrapHandler("list_tasks", s.listTasksHandler()), ) - s.mcpServer.AddTool( mcp.NewTool("create_task", mcp.WithDescription("Create a new task on a board."), @@ -233,7 +237,6 @@ func (s *Server) registerTools() { ), s.wrapHandler("create_task", s.createTaskHandler()), ) - s.mcpServer.AddTool( mcp.NewTool("update_task", mcp.WithDescription("Update an existing task."), @@ -244,7 +247,9 @@ func (s *Server) registerTools() { ), s.wrapHandler("update_task", s.updateTaskHandler()), ) +} +func (s *Server) registerInteractionTools() { s.mcpServer.AddTool( mcp.NewTool("ask_user_question", mcp.WithDescription(`Ask the user a clarifying question with multiple choice options. @@ -298,7 +303,9 @@ Another example: ), s.wrapHandler("ask_user_question", s.askUserQuestionHandler()), ) +} +func (s *Server) registerPlanTools() { s.mcpServer.AddTool( mcp.NewTool("create_task_plan", mcp.WithDescription("Create or save a task plan. Use this to save your implementation plan for the current task."), @@ -308,7 +315,6 @@ Another example: ), s.wrapHandler("create_task_plan", s.createTaskPlanHandler()), ) - s.mcpServer.AddTool( mcp.NewTool("get_task_plan", mcp.WithDescription("Get the current plan for a task. Use this to retrieve an existing plan, including any user edits."), @@ -316,7 +322,6 @@ Another example: ), s.wrapHandler("get_task_plan", s.getTaskPlanHandler()), ) - s.mcpServer.AddTool( mcp.NewTool("update_task_plan", mcp.WithDescription("Update an existing task plan. Use this to modify the plan during implementation."), @@ -326,7 +331,6 @@ Another example: ), s.wrapHandler("update_task_plan", s.updateTaskPlanHandler()), ) - s.mcpServer.AddTool( mcp.NewTool("delete_task_plan", mcp.WithDescription("Delete a task plan."), @@ -334,6 +338,4 @@ Another example: ), s.wrapHandler("delete_task_plan", s.deleteTaskPlanHandler()), ) - - s.logger.Info("registered MCP tools", zap.Int("count", 11)) } diff --git a/apps/backend/internal/agentctl/server/process/cmdline.go b/apps/backend/internal/agentctl/server/process/cmdline.go index 8b646bc9be..1d61390cf9 100644 --- a/apps/backend/internal/agentctl/server/process/cmdline.go +++ b/apps/backend/internal/agentctl/server/process/cmdline.go @@ -2,23 +2,10 @@ package process import "strings" -// escapeArg rewrites a command-line argument following the MSDN -// CommandLineToArgvW parsing rules (same algorithm as Go's -// syscall.EscapeArg on Windows): -// -// - every backslash (\) is doubled, but only if immediately -// followed by a double quote ("); -// - every double quote (") is escaped with a backslash (\); -// - finally, the argument is wrapped in double quotes only if -// it contains spaces or tabs. -// - an empty string becomes "" (two double-quote characters). -func escapeArg(s string) string { - if len(s) == 0 { - return `""` - } - - needsBackslash := false - hasSpace := false +// scanArgChars inspects each byte of s and returns whether any character +// requires backslash-escaping (double-quote or backslash) and whether +// the argument contains whitespace (space or tab). +func scanArgChars(s string) (needsBackslash, hasSpace bool) { for i := 0; i < len(s); i++ { switch s[i] { case '"', '\\': @@ -27,18 +14,13 @@ func escapeArg(s string) string { hasSpace = true } } + return needsBackslash, hasSpace +} - if !needsBackslash && !hasSpace { - return s - } - if !needsBackslash { - return `"` + s + `"` - } - - var b []byte - if hasSpace { - b = append(b, '"') - } +// appendEscapedBytes appends the bytes of s to b applying MSDN +// CommandLineToArgvW backslash-doubling rules for double-quote characters. +// Returns the updated byte slice and the trailing slash count. +func appendEscapedBytes(b []byte, s string) ([]byte, int) { slashes := 0 for i := 0; i < len(s); i++ { c := s[i] @@ -55,6 +37,38 @@ func escapeArg(s string) string { } b = append(b, c) } + return b, slashes +} + +// escapeArg rewrites a command-line argument following the MSDN +// CommandLineToArgvW parsing rules (same algorithm as Go's +// syscall.EscapeArg on Windows): +// +// - every backslash (\) is doubled, but only if immediately +// followed by a double quote ("); +// - every double quote (") is escaped with a backslash (\); +// - finally, the argument is wrapped in double quotes only if +// it contains spaces or tabs. +// - an empty string becomes "" (two double-quote characters). +func escapeArg(s string) string { + if len(s) == 0 { + return `""` + } + + needsBackslash, hasSpace := scanArgChars(s) + + if !needsBackslash && !hasSpace { + return s + } + if !needsBackslash { + return `"` + s + `"` + } + + var b []byte + if hasSpace { + b = append(b, '"') + } + b, slashes := appendEscapedBytes(b, s) if hasSpace { for ; slashes > 0; slashes-- { b = append(b, '\\') diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index 372bc8938f..7ff34a3807 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -567,49 +567,10 @@ func (g *GitOperator) Discard(ctx context.Context, paths []string) (*GitOperatio } } - var outputs []string - var errors []string - - // Handle untracked/new files - remove them from filesystem and index - if len(untrackedFiles) > 0 { - for _, path := range untrackedFiles { - // First, remove from index if staged - resetArgs := []string{"rm", "--cached", "--force", "--", path} - resetOutput, resetErr := g.runGitCommand(ctx, resetArgs...) - if resetErr != nil && !strings.Contains(resetErr.Error(), "did not match any files") { - // Ignore "did not match" errors as file might not be in index - errors = append(errors, fmt.Sprintf("failed to unstage %s: %s", path, resetErr.Error())) - } - if resetOutput != "" { - outputs = append(outputs, resetOutput) - } - - // Then remove from working tree - fullPath := filepath.Join(g.workDir, path) - if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { - errors = append(errors, fmt.Sprintf("failed to remove %s: %s", path, err.Error())) - } - } - } - - // Handle tracked files - restore from HEAD - if len(trackedFiles) > 0 { - // Use git restore to discard changes (Git 2.23+) - // --source=HEAD: restore from HEAD - // --staged: remove from index - // --worktree: remove from working tree - // --: separator between options and paths - args := append([]string{"restore", "--source=HEAD", "--staged", "--worktree", "--"}, trackedFiles...) - - output, err := g.runGitCommand(ctx, args...) - if output != "" { - outputs = append(outputs, output) - } - - if err != nil { - errors = append(errors, err.Error()) - } - } + outputs, errors := g.discardUntrackedFiles(ctx, untrackedFiles) + trackedOutputs, trackedErrors := g.discardTrackedFiles(ctx, trackedFiles) + outputs = append(outputs, trackedOutputs...) + errors = append(errors, trackedErrors...) // Combine outputs and errors result.Output = strings.Join(outputs, "\n") @@ -629,6 +590,39 @@ func (g *GitOperator) Discard(ctx context.Context, paths []string) (*GitOperatio return result, nil } +func (g *GitOperator) discardUntrackedFiles(ctx context.Context, paths []string) (outputs, errors []string) { + for _, path := range paths { + resetArgs := []string{"rm", "--cached", "--force", "--", path} + resetOutput, resetErr := g.runGitCommand(ctx, resetArgs...) + if resetErr != nil && !strings.Contains(resetErr.Error(), "did not match any files") { + errors = append(errors, fmt.Sprintf("failed to unstage %s: %s", path, resetErr.Error())) + } + if resetOutput != "" { + outputs = append(outputs, resetOutput) + } + fullPath := filepath.Join(g.workDir, path) + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { + errors = append(errors, fmt.Sprintf("failed to remove %s: %s", path, err.Error())) + } + } + return outputs, errors +} + +func (g *GitOperator) discardTrackedFiles(ctx context.Context, paths []string) (outputs, errors []string) { + if len(paths) == 0 { + return nil, nil + } + args := append([]string{"restore", "--source=HEAD", "--staged", "--worktree", "--"}, paths...) + output, err := g.runGitCommand(ctx, args...) + if output != "" { + outputs = append(outputs, output) + } + if err != nil { + errors = append(errors, err.Error()) + } + return outputs, errors +} + // Abort aborts an in-progress merge or rebase operation. func (g *GitOperator) Abort(ctx context.Context, operation string) (*GitOperationResult, error) { if !g.tryLock("abort") { @@ -678,6 +672,41 @@ type CommitDiffResult struct { Error string `json:"error,omitempty"` } +// isHexChar reports whether r is a valid hexadecimal digit (0-9, a-f, A-F). +func isHexChar(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') +} + +// validateCommitSHA returns an error string if sha is not a valid hex SHA, or "" if it is valid. +func validateCommitSHA(sha string) string { + if sha == "" || len(sha) > 64 { + return "invalid commit SHA" + } + for _, c := range sha { + if !isHexChar(c) { + return "invalid commit SHA: must be hexadecimal" + } + } + return "" +} + +// sumFileDiffStats adds up insertions and deletions across all parsed file entries. +func sumFileDiffStats(files map[string]interface{}) (insertions, deletions int) { + for _, fileInfo := range files { + fi, ok := fileInfo.(map[string]interface{}) + if !ok { + continue + } + if additions, ok := fi["additions"].(int); ok { + insertions += additions + } + if dels, ok := fi["deletions"].(int); ok { + deletions += dels + } + } + return insertions, deletions +} + // ShowCommit gets the diff for a specific commit using git show. func (g *GitOperator) ShowCommit(ctx context.Context, commitSHA string) (*CommitDiffResult, error) { result := &CommitDiffResult{ @@ -685,19 +714,10 @@ func (g *GitOperator) ShowCommit(ctx context.Context, commitSHA string) (*Commit } // Validate commit SHA (basic validation - alphanumeric only) - if commitSHA == "" || len(commitSHA) > 64 { - result.Error = "invalid commit SHA" + if errMsg := validateCommitSHA(commitSHA); errMsg != "" { + result.Error = errMsg return result, nil } - for _, c := range commitSHA { - isDigit := c >= '0' && c <= '9' - isLowerHex := c >= 'a' && c <= 'f' - isUpperHex := c >= 'A' && c <= 'F' - if !isDigit && !isLowerHex && !isUpperHex { - result.Error = "invalid commit SHA: must be hexadecimal" - return result, nil - } - } // Get commit metadata formatOutput, err := g.runGitCommand(ctx, "show", "--no-patch", "--format=%H%n%s%n%an <%ae>%n%aI", commitSHA) @@ -724,18 +744,7 @@ func (g *GitOperator) ShowCommit(ctx context.Context, commitSHA string) (*Commit // Parse the diff output into files result.Files = g.parseCommitDiff(diffOutput) result.FilesChanged = len(result.Files) - - // Calculate total insertions/deletions - for _, fileInfo := range result.Files { - if fi, ok := fileInfo.(map[string]interface{}); ok { - if additions, ok := fi["additions"].(int); ok { - result.Insertions += additions - } - if deletions, ok := fi["deletions"].(int); ok { - result.Deletions += deletions - } - } - } + result.Insertions, result.Deletions = sumFileDiffStats(result.Files) result.Success = true return result, nil @@ -778,12 +787,13 @@ func (g *GitOperator) parseCommitDiff(output string) map[string]interface{} { filePath := strings.TrimPrefix(bPath, "b/") // Determine file status from diff content - status := "modified" - if strings.Contains(diffContent, "new file mode") { + status := fileStatusModified + switch { + case strings.Contains(diffContent, "new file mode"): status = "added" - } else if strings.Contains(diffContent, "deleted file mode") { - status = "deleted" - } else if strings.Contains(diffContent, "rename from") { + case strings.Contains(diffContent, "deleted file mode"): + status = fileStatusDeleted + case strings.Contains(diffContent, "rename from"): status = "renamed" } diff --git a/apps/backend/internal/agentctl/server/process/interactive_io.go b/apps/backend/internal/agentctl/server/process/interactive_io.go new file mode 100644 index 0000000000..3de5fffe6f --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/interactive_io.go @@ -0,0 +1,216 @@ +package process + +import ( + "time" + + "go.uber.org/zap" +) + +func (r *InteractiveRunner) readOutput(proc *interactiveProcess) { + buf := make([]byte, 32768) // 32KB buffer for better throughput + recentOutput := "" // Keep recent output for prompt pattern matching + firstRead := true + + for { + select { + case <-proc.stopSignal: + r.logger.Debug("readOutput: stop signal received", + zap.String("process_id", proc.info.ID)) + return + default: + } + + proc.mu.Lock() + ptyInstance := proc.ptmx + proc.mu.Unlock() + + if ptyInstance == nil { + r.logger.Debug("readOutput: pty is nil, exiting", + zap.String("process_id", proc.info.ID)) + return + } + + n, err := ptyInstance.Read(buf) + if firstRead { + r.logger.Info("readOutput: first read attempt", + zap.String("process_id", proc.info.ID), + zap.Int("bytes", n), + zap.Error(err)) + firstRead = false + } + if n > 0 { + recentOutput = r.processOutputData(proc, ptyInstance, buf[:n], recentOutput) + } + if err != nil { + r.logger.Debug("interactive process output read ended", + zap.String("process_id", proc.info.ID), + zap.Error(err)) + return + } + } +} + +// respondToTerminalQueries sends synthetic terminal responses (DSR/DA1) to the PTY +// when no direct output writer (real terminal) is connected yet. +func (r *InteractiveRunner) respondToTerminalQueries(proc *interactiveProcess, ptyInstance PtyHandle, data []byte) { + if containsDSRQuery(data) { + response := "\x1b[1;1R" + if _, err := ptyInstance.Write([]byte(response)); err != nil { + r.logger.Debug("failed to respond to cursor position query", + zap.String("process_id", proc.info.ID), + zap.Error(err)) + } else { + r.logger.Debug("responded to cursor position query", + zap.String("process_id", proc.info.ID)) + } + } + if containsDA1Query(data) { + response := "\x1b[?1;2c" + if _, err := ptyInstance.Write([]byte(response)); err != nil { + r.logger.Debug("failed to respond to device attributes query", + zap.String("process_id", proc.info.ID), + zap.Error(err)) + } + } +} + +// processOutputData handles a chunk of output data read from the PTY. +// It responds to terminal queries, feeds the status tracker, buffers the output, +// routes it to the direct writer or event bus, and manages the prompt-pattern window. +// Returns the updated recentOutput string. +func (r *InteractiveRunner) processOutputData(proc *interactiveProcess, ptyInstance PtyHandle, data []byte, recentOutput string) string { + dataStr := string(data) + + // Respond to cursor position queries (DSR) if no terminal is connected yet. + // Some CLI tools (like Codex) query cursor position on startup with \e[6n + // and expect a response \e[row;colR. Without this, they timeout and exit. + // Only respond if no direct output writer is connected (no real terminal yet). + proc.directOutputMu.RLock() + hasDirectWriter := proc.directOutput != nil + proc.directOutputMu.RUnlock() + + if !hasDirectWriter { + r.respondToTerminalQueries(proc, ptyInstance, data) + } + + // Feed to status tracker for TUI state detection + if proc.statusTracker != nil { + proc.statusTracker.Write(data) + + // Periodically check state (debounced by ShouldCheck) + if proc.statusTracker.ShouldCheck() { + newState := proc.statusTracker.CheckAndUpdate() + if newState != proc.lastState { + proc.lastState = newState + r.handleStateChange(proc, newState) + } + } + } + + // Always buffer output for scrollback restoration on reconnect + chunk := ProcessOutputChunk{ + Stream: "stdout", + Data: dataStr, + Timestamp: time.Now().UTC(), + } + proc.buffer.append(chunk) + + // Check if we have a direct output writer (binary WebSocket mode) + proc.directOutputMu.RLock() + directWriter := proc.directOutput + proc.directOutputMu.RUnlock() + + if directWriter != nil { + // Direct mode: write raw bytes to the WebSocket + if _, writeErr := directWriter.Write(data); writeErr != nil { + r.logger.Debug("direct output write error", + zap.String("process_id", proc.info.ID), + zap.Error(writeErr)) + // Don't return - the writer might have closed but process continues + } + } else { + // No WebSocket connected: also publish via event bus + r.publishOutput(proc, chunk) + } + + recentOutput = r.updateRecentOutput(recentOutput, dataStr) + + // Check prompt pattern for turn completion + if proc.promptPattern != nil && proc.promptPattern.MatchString(recentOutput) { + r.emitTurnComplete(proc) + recentOutput = "" // Reset after match + } + + // Reset idle timer on any output + r.resetIdleTimer(proc) + + return recentOutput +} + +// updateRecentOutput appends new output to the rolling 1KB window used for prompt +// pattern matching, trimming the oldest data to stay within the size limit. +func (r *InteractiveRunner) updateRecentOutput(recentOutput, dataStr string) string { + // Update recent output for prompt pattern matching (keep last 1KB) + // Trim before appending to prevent temporary memory spikes with large outputs + maxSize := 1024 + if len(recentOutput)+len(dataStr) > maxSize { + // Calculate how much to keep from existing buffer + keepFromExisting := maxSize - len(dataStr) + if keepFromExisting < 0 { + keepFromExisting = 0 + } + if keepFromExisting > 0 && len(recentOutput) > keepFromExisting { + recentOutput = recentOutput[len(recentOutput)-keepFromExisting:] + } else if keepFromExisting == 0 { + recentOutput = "" + } + } + recentOutput += dataStr + // Final trim in case dataStr itself was larger than maxSize + if len(recentOutput) > maxSize { + recentOutput = recentOutput[len(recentOutput)-maxSize:] + } + return recentOutput +} + +func (r *InteractiveRunner) resetIdleTimer(proc *interactiveProcess) { + proc.idleTimerMu.Lock() + defer proc.idleTimerMu.Unlock() + + if proc.idleTimer != nil { + proc.idleTimer.Stop() + } + + if proc.idleTimeout > 0 { + proc.idleTimer = time.AfterFunc(proc.idleTimeout, func() { + r.emitTurnComplete(proc) + }) + } +} + +func (r *InteractiveRunner) emitTurnComplete(proc *interactiveProcess) { + if r.turnCompleteCallback != nil { + r.turnCompleteCallback(proc.info.SessionID) + } + r.logger.Debug("turn complete detected", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID)) +} + +// handleStateChange processes agent state changes detected by the status tracker. +func (r *InteractiveRunner) handleStateChange(proc *interactiveProcess, state AgentState) { + r.logger.Debug("agent state changed", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID), + zap.String("state", string(state))) + + // WaitingInput state triggers turn complete + if state == StateWaitingInput { + r.emitTurnComplete(proc) + } + + // Invoke the state callback for external handling + if r.stateCallback != nil { + r.stateCallback(proc.info.SessionID, state) + } +} diff --git a/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go b/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go new file mode 100644 index 0000000000..03de479076 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go @@ -0,0 +1,588 @@ +package process + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "sync" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// interactiveProcess represents a running interactive PTY process. +type interactiveProcess struct { + info InteractiveProcessInfo + cmd *exec.Cmd + ptmx PtyHandle // PTY handle (Unix: creack/pty, Windows: ConPTY) + buffer *ringBuffer + + // Turn detection + promptPattern *regexp.Regexp + idleTimeout time.Duration + idleTimer *time.Timer + idleTimerMu sync.Mutex + + // Status tracking (vt10x-based TUI detection) + statusTracker *StatusTracker + lastState AgentState + + // User shell flag - when true, process is excluded from session-level lookups + // (ResizeBySession, GetPtyWriterBySession) to prevent conflicts with passthrough processes + isUserShell bool + + // Deferred start - process created lazily on first resize + // This ensures PTY is created at exact frontend dimensions + started bool + startOnce sync.Once + startCmd []string + startDir string + startEnv map[string]string + startReq InteractiveStartRequest // Full request for deferred initialization + + // Direct output - when set, raw output goes here instead of event bus + directOutput DirectOutputWriter + directOutputMu sync.RWMutex + + // WebSocket tracking - tracks whether a WebSocket is actively connected + hasActiveWebSocket bool + + // Lifecycle + stopOnce sync.Once + stopSignal chan struct{} + waitDone chan struct{} // closed when wait() returns (cmd.Wait completed) + mu sync.Mutex +} + +// Start creates an interactive process entry and defers PTY creation until first resize. +// This ensures the PTY is created at exact frontend dimensions, preventing redraw issues. +func (r *InteractiveRunner) Start(ctx context.Context, req InteractiveStartRequest) (*InteractiveProcessInfo, error) { + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + if len(req.Command) == 0 { + return nil, fmt.Errorf("command is required") + } + + id := uuid.New().String() + now := time.Now().UTC() + + bufferMaxBytes := req.BufferMaxBytes + if bufferMaxBytes <= 0 { + bufferMaxBytes = r.bufferMaxBytes + } + + // Compile prompt pattern if provided + var promptPattern *regexp.Regexp + if req.PromptPattern != "" { + var compileErr error + promptPattern, compileErr = regexp.Compile(req.PromptPattern) + if compileErr != nil { + r.logger.Warn("failed to compile prompt pattern, turn detection may not work", + zap.String("pattern", req.PromptPattern), + zap.Error(compileErr)) + } + } + + var idleTimeout time.Duration + if req.DisableTurnDetection { + idleTimeout = 0 // No idle timer for user shell terminals + } else { + idleTimeout = req.IdleTimeout + if idleTimeout <= 0 { + idleTimeout = 5 * time.Second // Default 5 seconds + } + } + + // Create process struct WITHOUT spawning PTY yet + // PTY will be created on first resize when we know the exact dimensions + proc := &interactiveProcess{ + info: InteractiveProcessInfo{ + ID: id, + SessionID: req.SessionID, + Command: req.Command, + WorkingDir: req.WorkingDir, + Status: types.ProcessStatusRunning, + StartedAt: now, + UpdatedAt: now, + }, + buffer: newRingBuffer(bufferMaxBytes), + promptPattern: promptPattern, + idleTimeout: idleTimeout, + lastState: StateUnknown, + isUserShell: req.IsUserShell, + stopSignal: make(chan struct{}), + waitDone: make(chan struct{}), + // Store start parameters for deferred initialization + started: false, + startCmd: req.Command, + startDir: req.WorkingDir, + startEnv: req.Env, + startReq: req, + } + + r.mu.Lock() + r.processes[id] = proc + r.mu.Unlock() + + // If immediate start is requested, start with default dimensions + if req.ImmediateStart { + if err := r.immediateStartProcess(req, proc, id); err != nil { + return nil, err + } + } else { + r.logger.Info("interactive process created (waiting for terminal dimensions)", + zap.String("process_id", id), + zap.String("session_id", req.SessionID), + zap.Strings("command", req.Command), + zap.String("working_dir", req.WorkingDir), + ) + } + + r.publishStatus(proc) + + info := proc.snapshot(false) + return &info, nil +} + +// immediateStartProcess starts the PTY process immediately using default or provided dimensions. +func (r *InteractiveRunner) immediateStartProcess(req InteractiveStartRequest, proc *interactiveProcess, id string) error { + cols := req.DefaultCols + rows := req.DefaultRows + if cols <= 0 { + cols = 120 + } + if rows <= 0 { + rows = 40 + } + var startErr error + proc.startOnce.Do(func() { + r.logger.Info("immediate start - starting process with default dimensions", + zap.String("process_id", id), + zap.String("session_id", req.SessionID), + zap.Int("cols", cols), + zap.Int("rows", rows)) + startErr = r.startProcess(proc, cols, rows) + }) + if startErr != nil { + r.mu.Lock() + delete(r.processes, id) + r.mu.Unlock() + return fmt.Errorf("failed to start process: %w", startErr) + } + r.logger.Info("interactive process started immediately", + zap.String("process_id", id), + zap.String("session_id", req.SessionID), + zap.Strings("command", req.Command), + zap.String("working_dir", req.WorkingDir), + ) + return nil +} + +// startProcess actually spawns the PTY and process. Called on first resize. +func (r *InteractiveRunner) startProcess(proc *interactiveProcess, cols, rows int) error { + req := proc.startReq + + // Build command - use Background context so the process lives beyond the request + // The process lifecycle is managed by Stop() and wait(), not by context cancellation + cmd := exec.Command(proc.startCmd[0], proc.startCmd[1:]...) + if proc.startDir != "" { + cmd.Dir = proc.startDir + } + cmd.Env = mergeEnv(proc.startEnv) + // Note: Do NOT set Setpgid when using PTY - it conflicts with terminal control + // The PTY session handles process group management + + // Start process in PTY with exact dimensions from frontend + // Unix: creack/pty, Windows: ConPTY + ptmx, err := startPTYWithSize(cmd, cols, rows) + if err != nil { + return fmt.Errorf("failed to start pty: %w", err) + } + + // Create status tracker if a detector is configured + var statusTracker *StatusTracker + if req.StatusDetector != "" { + detector := createStatusDetector() + config := StatusTrackerConfig{ + Rows: rows, + Cols: cols, + CheckInterval: req.CheckInterval, + StabilityWindow: req.StabilityWindow, + } + if config.CheckInterval <= 0 { + config.CheckInterval = 100 * time.Millisecond + } + // Create callback that will invoke the runner's state callback + stateCallback := func(sessionID string, state AgentState) { + if r.stateCallback != nil { + r.stateCallback(sessionID, state) + } + } + statusTracker = NewStatusTracker(req.SessionID, detector, stateCallback, config, r.logger) + r.logger.Debug("status tracker created", + zap.String("session_id", req.SessionID), + zap.String("detector", req.StatusDetector)) + } + + proc.mu.Lock() + proc.ptmx = ptmx + proc.cmd = cmd + proc.statusTracker = statusTracker + proc.started = true + proc.mu.Unlock() + + pid := 0 + if cmd.Process != nil { + pid = cmd.Process.Pid + } + r.logger.Info("interactive process started at exact dimensions", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID), + zap.Int("cols", cols), + zap.Int("rows", rows), + zap.Int("pid", pid), + ) + + // Start output reading and process waiting goroutines + go r.readOutput(proc) + go r.wait(proc) + + // If an initial command was provided, write it to the PTY after a short delay + // to allow the shell to initialize and display its prompt + if req.InitialCommand != "" { + go func() { + time.Sleep(100 * time.Millisecond) + proc.mu.Lock() + pty := proc.ptmx + proc.mu.Unlock() + if pty != nil { + _, err := pty.Write([]byte(req.InitialCommand + "\n")) + if err != nil { + r.logger.Warn("failed to write initial command to PTY", + zap.String("process_id", proc.info.ID), + zap.Error(err)) + } else { + r.logger.Debug("wrote initial command to PTY", + zap.String("process_id", proc.info.ID), + zap.String("command", req.InitialCommand)) + } + } + }() + } + + return nil +} + +// WriteStdin writes data to the process stdin (through PTY). +func (r *InteractiveRunner) WriteStdin(processID string, data string) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + proc.mu.Lock() + started := proc.started + ptyInstance := proc.ptmx + proc.mu.Unlock() + + if !started { + return fmt.Errorf("process not started yet - waiting for terminal dimensions") + } + + if ptyInstance == nil { + return fmt.Errorf("process stdin not available") + } + + _, err := ptyInstance.Write([]byte(data)) + if err != nil { + return fmt.Errorf("failed to write to stdin: %w", err) + } + + // Reset idle timer when user sends input + r.resetIdleTimer(proc) + + return nil +} + +// Stop terminates an interactive process. +func (r *InteractiveRunner) Stop(ctx context.Context, processID string) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + // Signal output reader to exit + proc.stopOnce.Do(func() { + close(proc.stopSignal) + }) + + // Stop idle timer + proc.idleTimerMu.Lock() + if proc.idleTimer != nil { + proc.idleTimer.Stop() + } + proc.idleTimerMu.Unlock() + + // Close PTY (this will cause the process to receive SIGHUP) + proc.mu.Lock() + if proc.ptmx != nil { + _ = proc.ptmx.Close() + } + proc.mu.Unlock() + + // Terminate the process directly (PTY handles its own session management) + if proc.cmd != nil && proc.cmd.Process != nil { + _ = terminateProcess(proc.cmd.Process) + + // Wait for the wait() goroutine to finish (it calls cmd.Wait). + // If it doesn't exit in time, force-kill the process. + select { + case <-ctx.Done(): + _ = proc.cmd.Process.Kill() + case <-time.After(2 * time.Second): + _ = proc.cmd.Process.Kill() + case <-proc.waitDone: + // Process exited cleanly + } + } + + return nil +} + +// Get retrieves process information by ID. +func (r *InteractiveRunner) Get(id string, includeOutput bool) (*InteractiveProcessInfo, bool) { + proc, ok := r.get(id) + if !ok { + return nil, false + } + info := proc.snapshot(includeOutput) + return &info, true +} + +// GetBySession retrieves process information by session ID. +func (r *InteractiveRunner) GetBySession(sessionID string) (*InteractiveProcessInfo, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, proc := range r.processes { + if proc.info.SessionID == sessionID { + info := proc.snapshot(false) + return &info, true + } + } + return nil, false +} + +// isProcessAlive checks if the underlying OS process is still running. +// Uses a non-blocking check on waitDone which is closed when cmd.Wait returns. +// Must be called with proc.mu held. +func (r *InteractiveRunner) isProcessAlive(proc *interactiveProcess) bool { + if proc.cmd == nil || proc.cmd.Process == nil { + return false + } + select { + case <-proc.waitDone: + return false + default: + return true + } +} + +// IsProcessRunning checks if a process with the given ID exists and is running. +// This is used to detect if a process was killed (e.g., after backend restart). +func (r *InteractiveRunner) IsProcessRunning(processID string) bool { + proc, ok := r.get(processID) + if !ok { + return false + } + + proc.mu.Lock() + defer proc.mu.Unlock() + + // Process must be started and alive + return proc.started && r.isProcessAlive(proc) +} + +// IsProcessReadyOrPending checks if a process exists and is either running or pending start. +// This is used by the terminal handler to allow connections to deferred-start processes +// that will start when the terminal sends dimensions. +func (r *InteractiveRunner) IsProcessReadyOrPending(processID string) bool { + proc, ok := r.get(processID) + if !ok { + return false + } + + proc.mu.Lock() + defer proc.mu.Unlock() + + // Process exists but hasn't started yet (deferred start) - this is OK + if !proc.started { + return true + } + + // Process started - check if still alive + return r.isProcessAlive(proc) +} + +// GetBuffer returns the buffered output for a process. +func (r *InteractiveRunner) GetBuffer(processID string) ([]ProcessOutputChunk, bool) { + proc, ok := r.get(processID) + if !ok { + return nil, false + } + return proc.buffer.snapshot(), true +} + +func (r *InteractiveRunner) get(id string) (*interactiveProcess, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + proc, ok := r.processes[id] + return proc, ok +} + +// wait blocks until the process exits and then cleans up. +// Note: cmd.Wait() is intentionally blocking without a timeout. This is the correct +// behavior because: +// 1. Wait() is required to reap the process and prevent zombies +// 2. Stuck processes should be terminated via Stop() which sends SIGTERM/SIGKILL +// 3. Adding a timeout here would leave the process unreachable and create leaks +func (r *InteractiveRunner) wait(proc *interactiveProcess) { + defer close(proc.waitDone) + + proc.mu.Lock() + ptyHandle := proc.ptmx + proc.mu.Unlock() + + exitCode, signalName, err := waitPtyProcess(proc.cmd, ptyHandle) + status := types.ProcessStatusExited + if err != nil { + status = types.ProcessStatusFailed + } + + r.logger.Info("interactive 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.String("signal", signalName), + zap.Error(err), + ) + + // Log buffer contents if process exited with error (helps debug startup failures) + // Use Debug level since non-zero exit is normal for killed processes (e.g., user closing terminal) + if status == types.ProcessStatusFailed && proc.buffer != nil { + chunks := proc.buffer.snapshot() + if len(chunks) > 0 { + var combinedOutput string + for _, chunk := range chunks { + combinedOutput += chunk.Data + } + // Truncate for logging (max 2000 chars) + if len(combinedOutput) > 2000 { + combinedOutput = combinedOutput[:2000] + "...(truncated)" + } + r.logger.Debug("interactive process output before exit", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID), + zap.Int("exit_code", exitCode), + zap.String("output", combinedOutput), + ) + } + } + + // Stop idle timer + proc.idleTimerMu.Lock() + if proc.idleTimer != nil { + proc.idleTimer.Stop() + } + proc.idleTimerMu.Unlock() + + // Update process info + proc.mu.Lock() + proc.info.Status = status + proc.info.ExitCode = &exitCode + proc.info.UpdatedAt = time.Now().UTC() + proc.mu.Unlock() + + // Close PTY + proc.mu.Lock() + if proc.ptmx != nil { + _ = proc.ptmx.Close() + proc.ptmx = nil + } + proc.mu.Unlock() + + r.publishStatus(proc) + + // Remove from tracking + r.mu.Lock() + delete(r.processes, proc.info.ID) + r.mu.Unlock() +} + +func (r *InteractiveRunner) publishOutput(proc *interactiveProcess, chunk ProcessOutputChunk) { + // No gating needed - process starts at exact frontend dimensions via lazy start + proc.mu.Lock() + info := proc.info + proc.mu.Unlock() + + output := &types.ProcessOutput{ + SessionID: info.SessionID, + ProcessID: info.ID, + Kind: types.ProcessKindAgentPassthrough, + Stream: chunk.Stream, + Data: chunk.Data, + Timestamp: chunk.Timestamp, + } + + // Use WorkspaceTracker if available, otherwise use callback + if r.workspaceTracker != nil { + r.workspaceTracker.notifyWorkspaceStreamProcessOutput(output) + } else if r.outputCallback != nil { + r.outputCallback(output) + } +} + +func (r *InteractiveRunner) publishStatus(proc *interactiveProcess) { + proc.mu.Lock() + info := proc.info + proc.mu.Unlock() + + // Convert []string command to single string for status update + cmdStr := "" + if len(info.Command) > 0 { + cmdStr = info.Command[0] + } + + update := &types.ProcessStatusUpdate{ + SessionID: info.SessionID, + ProcessID: info.ID, + Kind: types.ProcessKindAgentPassthrough, + Command: cmdStr, + WorkingDir: info.WorkingDir, + Status: info.Status, + ExitCode: info.ExitCode, + Timestamp: time.Now().UTC(), + } + + // Use WorkspaceTracker if available, otherwise use callback + if r.workspaceTracker != nil { + r.workspaceTracker.notifyWorkspaceStreamProcessStatus(update) + } else if r.statusCallback != nil { + r.statusCallback(update) + } +} + +func (p *interactiveProcess) snapshot(includeOutput bool) InteractiveProcessInfo { + p.mu.Lock() + defer p.mu.Unlock() + info := p.info + if includeOutput && p.buffer != nil { + info.Output = p.buffer.snapshot() + } + return info +} diff --git a/apps/backend/internal/agentctl/server/process/interactive_output.go b/apps/backend/internal/agentctl/server/process/interactive_output.go new file mode 100644 index 0000000000..a137afe581 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/interactive_output.go @@ -0,0 +1,272 @@ +package process + +import ( + "fmt" + "io" + + "go.uber.org/zap" +) + +// SetDirectOutput sets a direct output writer for a process. +// When set, PTY output bypasses the event bus and goes directly to this writer. +// This is used for the dedicated binary WebSocket in passthrough mode. +// For non-user-shell processes, also tracks the WebSocket at the session level +// to survive process restarts. User shells are excluded from session-level tracking +// to prevent overwriting the agent terminal's WebSocket. +// Returns error if process not found. +func (r *InteractiveRunner) SetDirectOutput(processID string, writer DirectOutputWriter) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + sessionID := proc.info.SessionID + + proc.directOutputMu.Lock() + proc.directOutput = writer + proc.hasActiveWebSocket = true + proc.directOutputMu.Unlock() + + // Track at session level for restart support (agent passthrough only). + // User shells have their own tracking via userShells map and must not + // overwrite the agent terminal's session-level WebSocket. + if !proc.isUserShell { + r.sessionWsMu.Lock() + r.sessionWs[sessionID] = &sessionWebSocket{writer: writer} + r.sessionWsMu.Unlock() + } + + r.logger.Info("direct output set for process", + zap.String("process_id", processID), + zap.String("session_id", sessionID), + zap.Bool("is_user_shell", proc.isUserShell)) + + return nil +} + +// ClearDirectOutput clears the direct output writer for a process. +// Output will return to the normal event bus path. +// For non-user-shell processes, also clears the session-level WebSocket tracking. +func (r *InteractiveRunner) ClearDirectOutput(processID string) error { + proc, ok := r.get(processID) + if !ok { + // Process may have been deleted - still try to clear session tracking + r.logger.Debug("process not found during ClearDirectOutput, trying to clear by session", + zap.String("process_id", processID)) + return nil + } + + sessionID := proc.info.SessionID + + proc.directOutputMu.Lock() + proc.directOutput = nil + proc.hasActiveWebSocket = false + proc.directOutputMu.Unlock() + + // Clear session-level tracking (agent passthrough only) + if !proc.isUserShell { + r.sessionWsMu.Lock() + delete(r.sessionWs, sessionID) + r.sessionWsMu.Unlock() + } + + r.logger.Info("direct output cleared for process", + zap.String("process_id", processID), + zap.String("session_id", sessionID)) + + return nil +} + +// ClearDirectOutputBySession clears the direct output for a session. +// This is used when the agent terminal WebSocket disconnects. +// Only clears non-user-shell processes to avoid disrupting user shell terminals. +func (r *InteractiveRunner) ClearDirectOutputBySession(sessionID string) { + // Clear session-level tracking + r.sessionWsMu.Lock() + delete(r.sessionWs, sessionID) + r.sessionWsMu.Unlock() + + // Clear from non-user-shell processes with this session + r.mu.RLock() + for _, proc := range r.processes { + if proc.info.SessionID == sessionID && !proc.isUserShell { + proc.directOutputMu.Lock() + proc.directOutput = nil + proc.hasActiveWebSocket = false + proc.directOutputMu.Unlock() + } + } + r.mu.RUnlock() + + r.logger.Info("direct output cleared for session", + zap.String("session_id", sessionID)) +} + +// ConnectSessionWebSocket connects an existing session WebSocket to a process. +// This is called when a new process starts for a session that already has an active WebSocket. +// Returns true if a WebSocket was connected. +func (r *InteractiveRunner) ConnectSessionWebSocket(processID string) bool { + proc, ok := r.get(processID) + if !ok { + return false + } + + sessionID := proc.info.SessionID + + r.sessionWsMu.RLock() + sessWs, exists := r.sessionWs[sessionID] + r.sessionWsMu.RUnlock() + + if !exists || sessWs == nil { + return false + } + + sessWs.mu.RLock() + writer := sessWs.writer + sessWs.mu.RUnlock() + + if writer == nil { + return false + } + + proc.directOutputMu.Lock() + proc.directOutput = writer + proc.hasActiveWebSocket = true + proc.directOutputMu.Unlock() + + r.logger.Info("connected existing session WebSocket to new process", + zap.String("process_id", processID), + zap.String("session_id", sessionID)) + + return true +} + +// GetPtyWriter returns a writer for sending input to the PTY. +// This is used for the dedicated binary WebSocket in passthrough mode. +func (r *InteractiveRunner) GetPtyWriter(processID string) (io.Writer, error) { + proc, ok := r.get(processID) + if !ok { + return nil, fmt.Errorf("process not found: %s", processID) + } + + proc.mu.Lock() + defer proc.mu.Unlock() + + if !proc.started { + return nil, fmt.Errorf("process not started yet - waiting for terminal dimensions") + } + + if proc.ptmx == nil { + return nil, fmt.Errorf("PTY not available") + } + + return proc.ptmx, nil +} + +// GetPtyWriterBySession returns a writer for sending input to the PTY for a session. +// This is used to reconnect after a process restart. +// Skips user shell processes to avoid conflicts with passthrough processes. +func (r *InteractiveRunner) GetPtyWriterBySession(sessionID string) (io.Writer, string, error) { + r.mu.RLock() + var proc *interactiveProcess + for _, p := range r.processes { + if p.info.SessionID == sessionID && !p.isUserShell { + proc = p + break + } + } + r.mu.RUnlock() + + if proc == nil { + return nil, "", fmt.Errorf("no process found for session: %s", sessionID) + } + + proc.mu.Lock() + defer proc.mu.Unlock() + + if !proc.started { + return nil, proc.info.ID, fmt.Errorf("process not started yet - waiting for terminal dimensions") + } + + if proc.ptmx == nil { + return nil, proc.info.ID, fmt.Errorf("PTY not available") + } + + return proc.ptmx, proc.info.ID, nil +} + +// HasActiveWebSocket checks if a process has an active WebSocket connection. +// This is used to determine if auto-restart should be attempted on process exit. +func (r *InteractiveRunner) HasActiveWebSocket(processID string) bool { + proc, ok := r.get(processID) + if !ok { + return false + } + + proc.directOutputMu.RLock() + defer proc.directOutputMu.RUnlock() + return proc.hasActiveWebSocket +} + +// HasActiveWebSocketBySession checks if a session has an active WebSocket connection. +// Uses session-level tracking which survives process restarts. +func (r *InteractiveRunner) HasActiveWebSocketBySession(sessionID string) bool { + r.sessionWsMu.RLock() + sessWs, exists := r.sessionWs[sessionID] + r.sessionWsMu.RUnlock() + + if !exists || sessWs == nil { + return false + } + + sessWs.mu.RLock() + hasWriter := sessWs.writer != nil + sessWs.mu.RUnlock() + + return hasWriter +} + +// WriteToDirectOutput writes data directly to the WebSocket output for a process. +// This is used to send messages like restart notifications to the terminal. +// Returns error if process not found or no direct output is set. +func (r *InteractiveRunner) WriteToDirectOutput(processID string, data []byte) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + proc.directOutputMu.RLock() + directWriter := proc.directOutput + proc.directOutputMu.RUnlock() + + if directWriter == nil { + return fmt.Errorf("no direct output writer set for process: %s", processID) + } + + _, err := directWriter.Write(data) + return err +} + +// WriteToDirectOutputBySession writes data directly to the WebSocket output for a session. +// This is used to send messages like restart notifications to the terminal. +// Uses session-level tracking which survives process restarts. +func (r *InteractiveRunner) WriteToDirectOutputBySession(sessionID string, data []byte) error { + r.sessionWsMu.RLock() + sessWs, exists := r.sessionWs[sessionID] + r.sessionWsMu.RUnlock() + + if !exists || sessWs == nil { + return fmt.Errorf("no WebSocket found for session: %s", sessionID) + } + + sessWs.mu.RLock() + writer := sessWs.writer + sessWs.mu.RUnlock() + + if writer == nil { + return fmt.Errorf("no direct output writer set for session: %s", sessionID) + } + + _, err := writer.Write(data) + return err +} diff --git a/apps/backend/internal/agentctl/server/process/interactive_resize.go b/apps/backend/internal/agentctl/server/process/interactive_resize.go new file mode 100644 index 0000000000..1f2b07f794 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/interactive_resize.go @@ -0,0 +1,100 @@ +package process + +import ( + "fmt" + + "go.uber.org/zap" +) + +// lazyStartAndResize handles the common pattern of lazy-starting a process on the first +// resize and then resizing its PTY and status tracker. All three public Resize* methods +// delegate here to avoid duplicating the start-once + resize logic. +func (r *InteractiveRunner) lazyStartAndResize(proc *interactiveProcess, cols, rows uint16, logFields ...zap.Field) error { + // Lazy start: spawn process on first resize when we have exact dimensions + var startErr error + proc.startOnce.Do(func() { + fields := append([]zap.Field{zap.Uint16("cols", cols), zap.Uint16("rows", rows)}, logFields...) + r.logger.Info("first resize received - starting process", fields...) + startErr = r.startProcess(proc, int(cols), int(rows)) + }) + if startErr != nil { + return fmt.Errorf("failed to start process on first resize: %w", startErr) + } + + proc.mu.Lock() + ptyInstance := proc.ptmx + statusTracker := proc.statusTracker + proc.mu.Unlock() + + // Resize the PTY (Unix: SIGWINCH, Windows: ConPTY dimensions) + if ptyInstance != nil { + if err := ptyInstance.Resize(cols, rows); err != nil { + return fmt.Errorf("failed to resize PTY: %w", err) + } + } + + // Also resize the status tracker's virtual terminal (if present) + if statusTracker != nil { + statusTracker.Resize(int(cols), int(rows)) + } + + return nil +} + +// ResizeByProcessID resizes the PTY for a specific process by its ID. +// On first resize, this triggers lazy process start at the exact frontend dimensions. +// This is preferred over ResizeBySession when the process ID is known, as it avoids +// ambiguity when multiple processes exist for the same session. +func (r *InteractiveRunner) ResizeByProcessID(processID string, cols, rows uint16) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + if err := r.lazyStartAndResize(proc, cols, rows, + zap.String("process_id", processID), + zap.String("session_id", proc.info.SessionID), + ); err != nil { + return err + } + + r.logger.Debug("resized PTY", + zap.String("process_id", processID), + zap.String("session_id", proc.info.SessionID), + zap.Uint16("cols", cols), + zap.Uint16("rows", rows)) + + return nil +} + +// ResizeBySession resizes the PTY for a process by session ID. +// On first resize, this triggers lazy process start at the exact frontend dimensions. +// Skips user shell processes to avoid conflicts with passthrough processes. +func (r *InteractiveRunner) ResizeBySession(sessionID string, cols, rows uint16) error { + r.mu.RLock() + var proc *interactiveProcess + for _, p := range r.processes { + if p.info.SessionID == sessionID && !p.isUserShell { + proc = p + break + } + } + r.mu.RUnlock() + + if proc == nil { + return fmt.Errorf("no process found for session %s", sessionID) + } + + if err := r.lazyStartAndResize(proc, cols, rows, + zap.String("session_id", sessionID), + ); err != nil { + return err + } + + r.logger.Debug("resized PTY", + zap.String("session_id", sessionID), + zap.Uint16("cols", cols), + zap.Uint16("rows", rows)) + + return nil +} diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner.go b/apps/backend/internal/agentctl/server/process/interactive_runner.go index 024d3af36c..88eeeae9a3 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_runner.go +++ b/apps/backend/internal/agentctl/server/process/interactive_runner.go @@ -7,18 +7,10 @@ package process import ( - "bytes" - "context" - "fmt" "io" - "os/exec" - "regexp" - "sort" - "strings" "sync" "time" - "github.com/google/uuid" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" @@ -64,50 +56,6 @@ type DirectOutputWriter interface { io.Closer } -// interactiveProcess represents a running interactive PTY process. -type interactiveProcess struct { - info InteractiveProcessInfo - cmd *exec.Cmd - ptmx PtyHandle // PTY handle (Unix: creack/pty, Windows: ConPTY) - buffer *ringBuffer - - // Turn detection - promptPattern *regexp.Regexp - idleTimeout time.Duration - idleTimer *time.Timer - idleTimerMu sync.Mutex - - // Status tracking (vt10x-based TUI detection) - statusTracker *StatusTracker - lastState AgentState - - // User shell flag - when true, process is excluded from session-level lookups - // (ResizeBySession, GetPtyWriterBySession) to prevent conflicts with passthrough processes - isUserShell bool - - // Deferred start - process created lazily on first resize - // This ensures PTY is created at exact frontend dimensions - started bool - startOnce sync.Once - startCmd []string - startDir string - startEnv map[string]string - startReq InteractiveStartRequest // Full request for deferred initialization - - // Direct output - when set, raw output goes here instead of event bus - directOutput DirectOutputWriter - directOutputMu sync.RWMutex - - // WebSocket tracking - tracks whether a WebSocket is actively connected - hasActiveWebSocket bool - - // Lifecycle - stopOnce sync.Once - stopSignal chan struct{} - waitDone chan struct{} // closed when wait() returns (cmd.Wait completed) - mu sync.Mutex -} - // TurnCompleteCallback is called when turn detection determines the agent is waiting for input. type TurnCompleteCallback func(sessionID string) @@ -199,1439 +147,3 @@ func (r *InteractiveRunner) SetStateCallback(cb AgentStateCallback) { func createStatusDetector() StatusDetector { return NewIdleDetector() } - -// Start creates an interactive process entry and defers PTY creation until first resize. -// This ensures the PTY is created at exact frontend dimensions, preventing redraw issues. -func (r *InteractiveRunner) Start(ctx context.Context, req InteractiveStartRequest) (*InteractiveProcessInfo, error) { - if req.SessionID == "" { - return nil, fmt.Errorf("session_id is required") - } - if len(req.Command) == 0 { - return nil, fmt.Errorf("command is required") - } - - id := uuid.New().String() - now := time.Now().UTC() - - bufferMaxBytes := req.BufferMaxBytes - if bufferMaxBytes <= 0 { - bufferMaxBytes = r.bufferMaxBytes - } - - // Compile prompt pattern if provided - var promptPattern *regexp.Regexp - if req.PromptPattern != "" { - var compileErr error - promptPattern, compileErr = regexp.Compile(req.PromptPattern) - if compileErr != nil { - r.logger.Warn("failed to compile prompt pattern, turn detection may not work", - zap.String("pattern", req.PromptPattern), - zap.Error(compileErr)) - } - } - - var idleTimeout time.Duration - if req.DisableTurnDetection { - idleTimeout = 0 // No idle timer for user shell terminals - } else { - idleTimeout = req.IdleTimeout - if idleTimeout <= 0 { - idleTimeout = 5 * time.Second // Default 5 seconds - } - } - - // Create process struct WITHOUT spawning PTY yet - // PTY will be created on first resize when we know the exact dimensions - proc := &interactiveProcess{ - info: InteractiveProcessInfo{ - ID: id, - SessionID: req.SessionID, - Command: req.Command, - WorkingDir: req.WorkingDir, - Status: types.ProcessStatusRunning, - StartedAt: now, - UpdatedAt: now, - }, - buffer: newRingBuffer(bufferMaxBytes), - promptPattern: promptPattern, - idleTimeout: idleTimeout, - lastState: StateUnknown, - isUserShell: req.IsUserShell, - stopSignal: make(chan struct{}), - waitDone: make(chan struct{}), - // Store start parameters for deferred initialization - started: false, - startCmd: req.Command, - startDir: req.WorkingDir, - startEnv: req.Env, - startReq: req, - } - - r.mu.Lock() - r.processes[id] = proc - r.mu.Unlock() - - // If immediate start is requested, start with default dimensions - if req.ImmediateStart { - cols := req.DefaultCols - rows := req.DefaultRows - if cols <= 0 { - cols = 120 // Default width - } - if rows <= 0 { - rows = 40 // Default height - } - - var startErr error - proc.startOnce.Do(func() { - r.logger.Info("immediate start - starting process with default dimensions", - zap.String("process_id", id), - zap.String("session_id", req.SessionID), - zap.Int("cols", cols), - zap.Int("rows", rows)) - startErr = r.startProcess(proc, cols, rows) - }) - if startErr != nil { - r.mu.Lock() - delete(r.processes, id) - r.mu.Unlock() - return nil, fmt.Errorf("failed to start process: %w", startErr) - } - - r.logger.Info("interactive process started immediately", - zap.String("process_id", id), - zap.String("session_id", req.SessionID), - zap.Strings("command", req.Command), - zap.String("working_dir", req.WorkingDir), - ) - } else { - r.logger.Info("interactive process created (waiting for terminal dimensions)", - zap.String("process_id", id), - zap.String("session_id", req.SessionID), - zap.Strings("command", req.Command), - zap.String("working_dir", req.WorkingDir), - ) - } - - r.publishStatus(proc) - - info := proc.snapshot(false) - return &info, nil -} - -// startProcess actually spawns the PTY and process. Called on first resize. -func (r *InteractiveRunner) startProcess(proc *interactiveProcess, cols, rows int) error { - req := proc.startReq - - // Build command - use Background context so the process lives beyond the request - // The process lifecycle is managed by Stop() and wait(), not by context cancellation - cmd := exec.Command(proc.startCmd[0], proc.startCmd[1:]...) - if proc.startDir != "" { - cmd.Dir = proc.startDir - } - cmd.Env = mergeEnv(proc.startEnv) - // Note: Do NOT set Setpgid when using PTY - it conflicts with terminal control - // The PTY session handles process group management - - // Start process in PTY with exact dimensions from frontend - // Unix: creack/pty, Windows: ConPTY - ptmx, err := startPTYWithSize(cmd, cols, rows) - if err != nil { - return fmt.Errorf("failed to start pty: %w", err) - } - - // Create status tracker if a detector is configured - var statusTracker *StatusTracker - if req.StatusDetector != "" { - detector := createStatusDetector() - config := StatusTrackerConfig{ - Rows: rows, - Cols: cols, - CheckInterval: req.CheckInterval, - StabilityWindow: req.StabilityWindow, - } - if config.CheckInterval <= 0 { - config.CheckInterval = 100 * time.Millisecond - } - // Create callback that will invoke the runner's state callback - stateCallback := func(sessionID string, state AgentState) { - if r.stateCallback != nil { - r.stateCallback(sessionID, state) - } - } - statusTracker = NewStatusTracker(req.SessionID, detector, stateCallback, config, r.logger) - r.logger.Debug("status tracker created", - zap.String("session_id", req.SessionID), - zap.String("detector", req.StatusDetector)) - } - - proc.mu.Lock() - proc.ptmx = ptmx - proc.cmd = cmd - proc.statusTracker = statusTracker - proc.started = true - proc.mu.Unlock() - - pid := 0 - if cmd.Process != nil { - pid = cmd.Process.Pid - } - r.logger.Info("interactive process started at exact dimensions", - zap.String("process_id", proc.info.ID), - zap.String("session_id", proc.info.SessionID), - zap.Int("cols", cols), - zap.Int("rows", rows), - zap.Int("pid", pid), - ) - - // Start output reading and process waiting goroutines - go r.readOutput(proc) - go r.wait(proc) - - // If an initial command was provided, write it to the PTY after a short delay - // to allow the shell to initialize and display its prompt - if req.InitialCommand != "" { - go func() { - time.Sleep(100 * time.Millisecond) - proc.mu.Lock() - pty := proc.ptmx - proc.mu.Unlock() - if pty != nil { - _, err := pty.Write([]byte(req.InitialCommand + "\n")) - if err != nil { - r.logger.Warn("failed to write initial command to PTY", - zap.String("process_id", proc.info.ID), - zap.Error(err)) - } else { - r.logger.Debug("wrote initial command to PTY", - zap.String("process_id", proc.info.ID), - zap.String("command", req.InitialCommand)) - } - } - }() - } - - return nil -} - -// WriteStdin writes data to the process stdin (through PTY). -func (r *InteractiveRunner) WriteStdin(processID string, data string) error { - proc, ok := r.get(processID) - if !ok { - return fmt.Errorf("process not found: %s", processID) - } - - proc.mu.Lock() - started := proc.started - ptyInstance := proc.ptmx - proc.mu.Unlock() - - if !started { - return fmt.Errorf("process not started yet - waiting for terminal dimensions") - } - - if ptyInstance == nil { - return fmt.Errorf("process stdin not available") - } - - _, err := ptyInstance.Write([]byte(data)) - if err != nil { - return fmt.Errorf("failed to write to stdin: %w", err) - } - - // Reset idle timer when user sends input - r.resetIdleTimer(proc) - - return nil -} - -// Stop terminates an interactive process. -func (r *InteractiveRunner) Stop(ctx context.Context, processID string) error { - proc, ok := r.get(processID) - if !ok { - return fmt.Errorf("process not found: %s", processID) - } - - // Signal output reader to exit - proc.stopOnce.Do(func() { - close(proc.stopSignal) - }) - - // Stop idle timer - proc.idleTimerMu.Lock() - if proc.idleTimer != nil { - proc.idleTimer.Stop() - } - proc.idleTimerMu.Unlock() - - // Close PTY (this will cause the process to receive SIGHUP) - proc.mu.Lock() - if proc.ptmx != nil { - _ = proc.ptmx.Close() - } - proc.mu.Unlock() - - // Terminate the process directly (PTY handles its own session management) - if proc.cmd != nil && proc.cmd.Process != nil { - _ = terminateProcess(proc.cmd.Process) - - // Wait for the wait() goroutine to finish (it calls cmd.Wait). - // If it doesn't exit in time, force-kill the process. - select { - case <-ctx.Done(): - _ = proc.cmd.Process.Kill() - case <-time.After(2 * time.Second): - _ = proc.cmd.Process.Kill() - case <-proc.waitDone: - // Process exited cleanly - } - } - - return nil -} - -// Get retrieves process information by ID. -func (r *InteractiveRunner) Get(id string, includeOutput bool) (*InteractiveProcessInfo, bool) { - proc, ok := r.get(id) - if !ok { - return nil, false - } - info := proc.snapshot(includeOutput) - return &info, true -} - -// GetBySession retrieves process information by session ID. -func (r *InteractiveRunner) GetBySession(sessionID string) (*InteractiveProcessInfo, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, proc := range r.processes { - if proc.info.SessionID == sessionID { - info := proc.snapshot(false) - return &info, true - } - } - return nil, false -} - -// isProcessAlive checks if the underlying OS process is still running. -// Uses a non-blocking check on waitDone which is closed when cmd.Wait returns. -// Must be called with proc.mu held. -func (r *InteractiveRunner) isProcessAlive(proc *interactiveProcess) bool { - if proc.cmd == nil || proc.cmd.Process == nil { - return false - } - select { - case <-proc.waitDone: - return false - default: - return true - } -} - -// IsProcessRunning checks if a process with the given ID exists and is running. -// This is used to detect if a process was killed (e.g., after backend restart). -func (r *InteractiveRunner) IsProcessRunning(processID string) bool { - proc, ok := r.get(processID) - if !ok { - return false - } - - proc.mu.Lock() - defer proc.mu.Unlock() - - // Process must be started and alive - return proc.started && r.isProcessAlive(proc) -} - -// IsProcessReadyOrPending checks if a process exists and is either running or pending start. -// This is used by the terminal handler to allow connections to deferred-start processes -// that will start when the terminal sends dimensions. -func (r *InteractiveRunner) IsProcessReadyOrPending(processID string) bool { - proc, ok := r.get(processID) - if !ok { - return false - } - - proc.mu.Lock() - defer proc.mu.Unlock() - - // Process exists but hasn't started yet (deferred start) - this is OK - if !proc.started { - return true - } - - // Process started - check if still alive - return r.isProcessAlive(proc) -} - -// GetBuffer returns the buffered output for a process. -func (r *InteractiveRunner) GetBuffer(processID string) ([]ProcessOutputChunk, bool) { - proc, ok := r.get(processID) - if !ok { - return nil, false - } - return proc.buffer.snapshot(), true -} - -// lazyStartAndResize handles the common pattern of lazy-starting a process on the first -// resize and then resizing its PTY and status tracker. All three public Resize* methods -// delegate here to avoid duplicating the start-once + resize logic. -func (r *InteractiveRunner) lazyStartAndResize(proc *interactiveProcess, cols, rows uint16, logFields ...zap.Field) error { - // Lazy start: spawn process on first resize when we have exact dimensions - var startErr error - proc.startOnce.Do(func() { - fields := append([]zap.Field{zap.Uint16("cols", cols), zap.Uint16("rows", rows)}, logFields...) - r.logger.Info("first resize received - starting process", fields...) - startErr = r.startProcess(proc, int(cols), int(rows)) - }) - if startErr != nil { - return fmt.Errorf("failed to start process on first resize: %w", startErr) - } - - proc.mu.Lock() - ptyInstance := proc.ptmx - statusTracker := proc.statusTracker - proc.mu.Unlock() - - // Resize the PTY (Unix: SIGWINCH, Windows: ConPTY dimensions) - if ptyInstance != nil { - if err := ptyInstance.Resize(cols, rows); err != nil { - return fmt.Errorf("failed to resize PTY: %w", err) - } - } - - // Also resize the status tracker's virtual terminal (if present) - if statusTracker != nil { - statusTracker.Resize(int(cols), int(rows)) - } - - return nil -} - -// ResizeByProcessID resizes the PTY for a specific process by its ID. -// On first resize, this triggers lazy process start at the exact frontend dimensions. -// This is preferred over ResizeBySession when the process ID is known, as it avoids -// ambiguity when multiple processes exist for the same session. -func (r *InteractiveRunner) ResizeByProcessID(processID string, cols, rows uint16) error { - proc, ok := r.get(processID) - if !ok { - return fmt.Errorf("process not found: %s", processID) - } - - if err := r.lazyStartAndResize(proc, cols, rows, - zap.String("process_id", processID), - zap.String("session_id", proc.info.SessionID), - ); err != nil { - return err - } - - r.logger.Debug("resized PTY", - zap.String("process_id", processID), - zap.String("session_id", proc.info.SessionID), - zap.Uint16("cols", cols), - zap.Uint16("rows", rows)) - - return nil -} - -// ResizeBySession resizes the PTY for a process by session ID. -// On first resize, this triggers lazy process start at the exact frontend dimensions. -// Skips user shell processes to avoid conflicts with passthrough processes. -func (r *InteractiveRunner) ResizeBySession(sessionID string, cols, rows uint16) error { - r.mu.RLock() - var proc *interactiveProcess - for _, p := range r.processes { - if p.info.SessionID == sessionID && !p.isUserShell { - proc = p - break - } - } - r.mu.RUnlock() - - if proc == nil { - return fmt.Errorf("no process found for session %s", sessionID) - } - - if err := r.lazyStartAndResize(proc, cols, rows, - zap.String("session_id", sessionID), - ); err != nil { - return err - } - - r.logger.Debug("resized PTY", - zap.String("session_id", sessionID), - zap.Uint16("cols", cols), - zap.Uint16("rows", rows)) - - return nil -} - -func (r *InteractiveRunner) get(id string) (*interactiveProcess, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - proc, ok := r.processes[id] - return proc, ok -} - -func (r *InteractiveRunner) readOutput(proc *interactiveProcess) { - buf := make([]byte, 32768) // 32KB buffer for better throughput - recentOutput := "" // Keep recent output for prompt pattern matching - firstRead := true - - for { - select { - case <-proc.stopSignal: - r.logger.Debug("readOutput: stop signal received", - zap.String("process_id", proc.info.ID)) - return - default: - } - - proc.mu.Lock() - ptyInstance := proc.ptmx - proc.mu.Unlock() - - if ptyInstance == nil { - r.logger.Debug("readOutput: pty is nil, exiting", - zap.String("process_id", proc.info.ID)) - return - } - - n, err := ptyInstance.Read(buf) - if firstRead { - r.logger.Info("readOutput: first read attempt", - zap.String("process_id", proc.info.ID), - zap.Int("bytes", n), - zap.Error(err)) - firstRead = false - } - if n > 0 { - data := buf[:n] - dataStr := string(data) - - // Respond to cursor position queries (DSR) if no terminal is connected yet. - // Some CLI tools (like Codex) query cursor position on startup with \e[6n - // and expect a response \e[row;colR. Without this, they timeout and exit. - // Only respond if no direct output writer is connected (no real terminal yet). - proc.directOutputMu.RLock() - hasDirectWriter := proc.directOutput != nil - proc.directOutputMu.RUnlock() - - if !hasDirectWriter { - // Check for cursor position query (DSR): ESC [ 6 n or ESC [ ? 6 n - if containsDSRQuery(data) { - // Respond with cursor at position 1,1 (top-left) - response := "\x1b[1;1R" - if _, err := ptyInstance.Write([]byte(response)); err != nil { - r.logger.Debug("failed to respond to cursor position query", - zap.String("process_id", proc.info.ID), - zap.Error(err)) - } else { - r.logger.Debug("responded to cursor position query", - zap.String("process_id", proc.info.ID)) - } - } - // Check for primary device attributes query (DA1): ESC [ c or ESC [ 0 c - if containsDA1Query(data) { - // Respond as VT100 terminal with advanced video option - response := "\x1b[?1;2c" - if _, err := ptyInstance.Write([]byte(response)); err != nil { - r.logger.Debug("failed to respond to device attributes query", - zap.String("process_id", proc.info.ID), - zap.Error(err)) - } - } - } - - // Feed to status tracker for TUI state detection - if proc.statusTracker != nil { - proc.statusTracker.Write(data) - - // Periodically check state (debounced by ShouldCheck) - if proc.statusTracker.ShouldCheck() { - newState := proc.statusTracker.CheckAndUpdate() - if newState != proc.lastState { - proc.lastState = newState - r.handleStateChange(proc, newState) - } - } - } - - // Always buffer output for scrollback restoration on reconnect - chunk := ProcessOutputChunk{ - Stream: "stdout", - Data: dataStr, - Timestamp: time.Now().UTC(), - } - proc.buffer.append(chunk) - - // Check if we have a direct output writer (binary WebSocket mode) - proc.directOutputMu.RLock() - directWriter := proc.directOutput - proc.directOutputMu.RUnlock() - - if directWriter != nil { - // Direct mode: write raw bytes to the WebSocket - if _, writeErr := directWriter.Write(data); writeErr != nil { - r.logger.Debug("direct output write error", - zap.String("process_id", proc.info.ID), - zap.Error(writeErr)) - // Don't return - the writer might have closed but process continues - } - } else { - // No WebSocket connected: also publish via event bus - r.publishOutput(proc, chunk) - } - - // Update recent output for prompt pattern matching (keep last 1KB) - // Trim before appending to prevent temporary memory spikes with large outputs - maxSize := 1024 - if len(recentOutput)+len(dataStr) > maxSize { - // Calculate how much to keep from existing buffer - keepFromExisting := maxSize - len(dataStr) - if keepFromExisting < 0 { - keepFromExisting = 0 - } - if keepFromExisting > 0 && len(recentOutput) > keepFromExisting { - recentOutput = recentOutput[len(recentOutput)-keepFromExisting:] - } else if keepFromExisting == 0 { - recentOutput = "" - } - } - recentOutput += dataStr - // Final trim in case dataStr itself was larger than maxSize - if len(recentOutput) > maxSize { - recentOutput = recentOutput[len(recentOutput)-maxSize:] - } - - // Check prompt pattern for turn completion - if proc.promptPattern != nil && proc.promptPattern.MatchString(recentOutput) { - r.emitTurnComplete(proc) - recentOutput = "" // Reset after match - } - - // Reset idle timer on any output - r.resetIdleTimer(proc) - } - if err != nil { - r.logger.Debug("interactive process output read ended", - zap.String("process_id", proc.info.ID), - zap.Error(err)) - return - } - } -} - -func (r *InteractiveRunner) resetIdleTimer(proc *interactiveProcess) { - proc.idleTimerMu.Lock() - defer proc.idleTimerMu.Unlock() - - if proc.idleTimer != nil { - proc.idleTimer.Stop() - } - - if proc.idleTimeout > 0 { - proc.idleTimer = time.AfterFunc(proc.idleTimeout, func() { - r.emitTurnComplete(proc) - }) - } -} - -func (r *InteractiveRunner) emitTurnComplete(proc *interactiveProcess) { - if r.turnCompleteCallback != nil { - r.turnCompleteCallback(proc.info.SessionID) - } - r.logger.Debug("turn complete detected", - zap.String("process_id", proc.info.ID), - zap.String("session_id", proc.info.SessionID)) -} - -// handleStateChange processes agent state changes detected by the status tracker. -func (r *InteractiveRunner) handleStateChange(proc *interactiveProcess, state AgentState) { - r.logger.Debug("agent state changed", - zap.String("process_id", proc.info.ID), - zap.String("session_id", proc.info.SessionID), - zap.String("state", string(state))) - - // WaitingInput state triggers turn complete - if state == StateWaitingInput { - r.emitTurnComplete(proc) - } - - // Invoke the state callback for external handling - if r.stateCallback != nil { - r.stateCallback(proc.info.SessionID, state) - } -} - -// wait blocks until the process exits and then cleans up. -// Note: cmd.Wait() is intentionally blocking without a timeout. This is the correct -// behavior because: -// 1. Wait() is required to reap the process and prevent zombies -// 2. Stuck processes should be terminated via Stop() which sends SIGTERM/SIGKILL -// 3. Adding a timeout here would leave the process unreachable and create leaks -func (r *InteractiveRunner) wait(proc *interactiveProcess) { - defer close(proc.waitDone) - - proc.mu.Lock() - ptyHandle := proc.ptmx - proc.mu.Unlock() - - exitCode, signalName, err := waitPtyProcess(proc.cmd, ptyHandle) - status := types.ProcessStatusExited - if err != nil { - status = types.ProcessStatusFailed - } - - r.logger.Info("interactive 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.String("signal", signalName), - zap.Error(err), - ) - - // Log buffer contents if process exited with error (helps debug startup failures) - // Use Debug level since non-zero exit is normal for killed processes (e.g., user closing terminal) - if status == types.ProcessStatusFailed && proc.buffer != nil { - chunks := proc.buffer.snapshot() - if len(chunks) > 0 { - var combinedOutput string - for _, chunk := range chunks { - combinedOutput += chunk.Data - } - // Truncate for logging (max 2000 chars) - if len(combinedOutput) > 2000 { - combinedOutput = combinedOutput[:2000] + "...(truncated)" - } - r.logger.Debug("interactive process output before exit", - zap.String("process_id", proc.info.ID), - zap.String("session_id", proc.info.SessionID), - zap.Int("exit_code", exitCode), - zap.String("output", combinedOutput), - ) - } - } - - // Stop idle timer - proc.idleTimerMu.Lock() - if proc.idleTimer != nil { - proc.idleTimer.Stop() - } - proc.idleTimerMu.Unlock() - - // Update process info - proc.mu.Lock() - proc.info.Status = status - proc.info.ExitCode = &exitCode - proc.info.UpdatedAt = time.Now().UTC() - proc.mu.Unlock() - - // Close PTY - proc.mu.Lock() - if proc.ptmx != nil { - _ = proc.ptmx.Close() - proc.ptmx = nil - } - proc.mu.Unlock() - - r.publishStatus(proc) - - // Remove from tracking - r.mu.Lock() - delete(r.processes, proc.info.ID) - r.mu.Unlock() -} - -func (r *InteractiveRunner) publishOutput(proc *interactiveProcess, chunk ProcessOutputChunk) { - // No gating needed - process starts at exact frontend dimensions via lazy start - proc.mu.Lock() - info := proc.info - proc.mu.Unlock() - - output := &types.ProcessOutput{ - SessionID: info.SessionID, - ProcessID: info.ID, - Kind: types.ProcessKindAgentPassthrough, - Stream: chunk.Stream, - Data: chunk.Data, - Timestamp: chunk.Timestamp, - } - - // Use WorkspaceTracker if available, otherwise use callback - if r.workspaceTracker != nil { - r.workspaceTracker.notifyWorkspaceStreamProcessOutput(output) - } else if r.outputCallback != nil { - r.outputCallback(output) - } -} - -func (r *InteractiveRunner) publishStatus(proc *interactiveProcess) { - proc.mu.Lock() - info := proc.info - proc.mu.Unlock() - - // Convert []string command to single string for status update - cmdStr := "" - if len(info.Command) > 0 { - cmdStr = info.Command[0] - } - - update := &types.ProcessStatusUpdate{ - SessionID: info.SessionID, - ProcessID: info.ID, - Kind: types.ProcessKindAgentPassthrough, - Command: cmdStr, - WorkingDir: info.WorkingDir, - Status: info.Status, - ExitCode: info.ExitCode, - Timestamp: time.Now().UTC(), - } - - // Use WorkspaceTracker if available, otherwise use callback - if r.workspaceTracker != nil { - r.workspaceTracker.notifyWorkspaceStreamProcessStatus(update) - } else if r.statusCallback != nil { - r.statusCallback(update) - } -} - -func (p *interactiveProcess) snapshot(includeOutput bool) InteractiveProcessInfo { - p.mu.Lock() - defer p.mu.Unlock() - info := p.info - if includeOutput && p.buffer != nil { - info.Output = p.buffer.snapshot() - } - return info -} - -// SetDirectOutput sets a direct output writer for a process. -// When set, PTY output bypasses the event bus and goes directly to this writer. -// This is used for the dedicated binary WebSocket in passthrough mode. -// For non-user-shell processes, also tracks the WebSocket at the session level -// to survive process restarts. User shells are excluded from session-level tracking -// to prevent overwriting the agent terminal's WebSocket. -// Returns error if process not found. -func (r *InteractiveRunner) SetDirectOutput(processID string, writer DirectOutputWriter) error { - proc, ok := r.get(processID) - if !ok { - return fmt.Errorf("process not found: %s", processID) - } - - sessionID := proc.info.SessionID - - proc.directOutputMu.Lock() - proc.directOutput = writer - proc.hasActiveWebSocket = true - proc.directOutputMu.Unlock() - - // Track at session level for restart support (agent passthrough only). - // User shells have their own tracking via userShells map and must not - // overwrite the agent terminal's session-level WebSocket. - if !proc.isUserShell { - r.sessionWsMu.Lock() - r.sessionWs[sessionID] = &sessionWebSocket{writer: writer} - r.sessionWsMu.Unlock() - } - - r.logger.Info("direct output set for process", - zap.String("process_id", processID), - zap.String("session_id", sessionID), - zap.Bool("is_user_shell", proc.isUserShell)) - - return nil -} - -// ClearDirectOutput clears the direct output writer for a process. -// Output will return to the normal event bus path. -// For non-user-shell processes, also clears the session-level WebSocket tracking. -func (r *InteractiveRunner) ClearDirectOutput(processID string) error { - proc, ok := r.get(processID) - if !ok { - // Process may have been deleted - still try to clear session tracking - r.logger.Debug("process not found during ClearDirectOutput, trying to clear by session", - zap.String("process_id", processID)) - return nil - } - - sessionID := proc.info.SessionID - - proc.directOutputMu.Lock() - proc.directOutput = nil - proc.hasActiveWebSocket = false - proc.directOutputMu.Unlock() - - // Clear session-level tracking (agent passthrough only) - if !proc.isUserShell { - r.sessionWsMu.Lock() - delete(r.sessionWs, sessionID) - r.sessionWsMu.Unlock() - } - - r.logger.Info("direct output cleared for process", - zap.String("process_id", processID), - zap.String("session_id", sessionID)) - - return nil -} - -// ClearDirectOutputBySession clears the direct output for a session. -// This is used when the agent terminal WebSocket disconnects. -// Only clears non-user-shell processes to avoid disrupting user shell terminals. -func (r *InteractiveRunner) ClearDirectOutputBySession(sessionID string) { - // Clear session-level tracking - r.sessionWsMu.Lock() - delete(r.sessionWs, sessionID) - r.sessionWsMu.Unlock() - - // Clear from non-user-shell processes with this session - r.mu.RLock() - for _, proc := range r.processes { - if proc.info.SessionID == sessionID && !proc.isUserShell { - proc.directOutputMu.Lock() - proc.directOutput = nil - proc.hasActiveWebSocket = false - proc.directOutputMu.Unlock() - } - } - r.mu.RUnlock() - - r.logger.Info("direct output cleared for session", - zap.String("session_id", sessionID)) -} - -// ConnectSessionWebSocket connects an existing session WebSocket to a process. -// This is called when a new process starts for a session that already has an active WebSocket. -// Returns true if a WebSocket was connected. -func (r *InteractiveRunner) ConnectSessionWebSocket(processID string) bool { - proc, ok := r.get(processID) - if !ok { - return false - } - - sessionID := proc.info.SessionID - - r.sessionWsMu.RLock() - sessWs, exists := r.sessionWs[sessionID] - r.sessionWsMu.RUnlock() - - if !exists || sessWs == nil { - return false - } - - sessWs.mu.RLock() - writer := sessWs.writer - sessWs.mu.RUnlock() - - if writer == nil { - return false - } - - proc.directOutputMu.Lock() - proc.directOutput = writer - proc.hasActiveWebSocket = true - proc.directOutputMu.Unlock() - - r.logger.Info("connected existing session WebSocket to new process", - zap.String("process_id", processID), - zap.String("session_id", sessionID)) - - return true -} - -// GetPtyWriter returns a writer for sending input to the PTY. -// This is used for the dedicated binary WebSocket in passthrough mode. -func (r *InteractiveRunner) GetPtyWriter(processID string) (io.Writer, error) { - proc, ok := r.get(processID) - if !ok { - return nil, fmt.Errorf("process not found: %s", processID) - } - - proc.mu.Lock() - defer proc.mu.Unlock() - - if !proc.started { - return nil, fmt.Errorf("process not started yet - waiting for terminal dimensions") - } - - if proc.ptmx == nil { - return nil, fmt.Errorf("PTY not available") - } - - return proc.ptmx, nil -} - -// GetPtyWriterBySession returns a writer for sending input to the PTY for a session. -// This is used to reconnect after a process restart. -// Skips user shell processes to avoid conflicts with passthrough processes. -func (r *InteractiveRunner) GetPtyWriterBySession(sessionID string) (io.Writer, string, error) { - r.mu.RLock() - var proc *interactiveProcess - for _, p := range r.processes { - if p.info.SessionID == sessionID && !p.isUserShell { - proc = p - break - } - } - r.mu.RUnlock() - - if proc == nil { - return nil, "", fmt.Errorf("no process found for session: %s", sessionID) - } - - proc.mu.Lock() - defer proc.mu.Unlock() - - if !proc.started { - return nil, proc.info.ID, fmt.Errorf("process not started yet - waiting for terminal dimensions") - } - - if proc.ptmx == nil { - return nil, proc.info.ID, fmt.Errorf("PTY not available") - } - - return proc.ptmx, proc.info.ID, nil -} - -// HasActiveWebSocket checks if a process has an active WebSocket connection. -// This is used to determine if auto-restart should be attempted on process exit. -func (r *InteractiveRunner) HasActiveWebSocket(processID string) bool { - proc, ok := r.get(processID) - if !ok { - return false - } - - proc.directOutputMu.RLock() - defer proc.directOutputMu.RUnlock() - return proc.hasActiveWebSocket -} - -// HasActiveWebSocketBySession checks if a session has an active WebSocket connection. -// Uses session-level tracking which survives process restarts. -func (r *InteractiveRunner) HasActiveWebSocketBySession(sessionID string) bool { - r.sessionWsMu.RLock() - sessWs, exists := r.sessionWs[sessionID] - r.sessionWsMu.RUnlock() - - if !exists || sessWs == nil { - return false - } - - sessWs.mu.RLock() - hasWriter := sessWs.writer != nil - sessWs.mu.RUnlock() - - return hasWriter -} - -// WriteToDirectOutput writes data directly to the WebSocket output for a process. -// This is used to send messages like restart notifications to the terminal. -// Returns error if process not found or no direct output is set. -func (r *InteractiveRunner) WriteToDirectOutput(processID string, data []byte) error { - proc, ok := r.get(processID) - if !ok { - return fmt.Errorf("process not found: %s", processID) - } - - proc.directOutputMu.RLock() - directWriter := proc.directOutput - proc.directOutputMu.RUnlock() - - if directWriter == nil { - return fmt.Errorf("no direct output writer set for process: %s", processID) - } - - _, err := directWriter.Write(data) - return err -} - -// WriteToDirectOutputBySession writes data directly to the WebSocket output for a session. -// This is used to send messages like restart notifications to the terminal. -// Uses session-level tracking which survives process restarts. -func (r *InteractiveRunner) WriteToDirectOutputBySession(sessionID string, data []byte) error { - r.sessionWsMu.RLock() - sessWs, exists := r.sessionWs[sessionID] - r.sessionWsMu.RUnlock() - - if !exists || sessWs == nil { - return fmt.Errorf("no WebSocket found for session: %s", sessionID) - } - - sessWs.mu.RLock() - writer := sessWs.writer - sessWs.mu.RUnlock() - - if writer == nil { - return fmt.Errorf("no direct output writer set for session: %s", sessionID) - } - - _, err := writer.Write(data) - return err -} - -// UserShellOptions contains optional parameters for starting a user shell. -type UserShellOptions struct { - Label string // Display name (e.g., "Terminal" or script name) - InitialCommand string // Command to run after shell starts - Closable *bool // Whether the terminal can be closed (nil = auto-determine) -} - -// CreateUserShellResult contains the result of creating a new user shell. -type CreateUserShellResult struct { - TerminalID string `json:"terminal_id"` - Label string `json:"label"` - Closable bool `json:"closable"` -} - -// CreateUserShell creates a new user shell terminal with auto-assigned ID and label. -// The first shell for a session is labeled "Terminal" and is not closable. -// Subsequent shells are labeled "Terminal 2", "Terminal 3", etc. and are closable. -// The entry is registered atomically to prevent races with ListUserShells. -func (r *InteractiveRunner) CreateUserShell(sessionID string) CreateUserShellResult { - r.userShellsMu.Lock() - defer r.userShellsMu.Unlock() - - // Count existing plain shell terminals for this session - prefix := sessionID + ":" - shellCount := 0 - for key, entry := range r.userShells { - if strings.HasPrefix(key, prefix) { - terminalID := key[len(prefix):] - // Only count plain shells (not script terminals) - if strings.HasPrefix(terminalID, "shell-") && entry.InitialCommand == "" { - shellCount++ - } - } - } - - // Generate terminal ID and label - terminalID := "shell-" + uuid.New().String() - - var label string - var closable bool - if shellCount == 0 { - label = "Terminal" - closable = false // First terminal is not closable - } else { - label = fmt.Sprintf("Terminal %d", shellCount+1) - closable = true - } - - // Register the entry so ListUserShells includes it immediately - r.userShells[prefix+terminalID] = &userShellEntry{ - ProcessID: "", // No process yet - will be started when WebSocket connects - Label: label, - InitialCommand: "", - Closable: closable, - CreatedAt: time.Now().UTC(), - } - - return CreateUserShellResult{ - TerminalID: terminalID, - Label: label, - Closable: closable, - } -} - -// RegisterScriptShell registers a script terminal entry so ListUserShells returns it. -// The actual process is not started until the WebSocket connects (StartUserShell handles that). -func (r *InteractiveRunner) RegisterScriptShell(sessionID, terminalID, label, initialCommand string) { - key := sessionID + ":" + terminalID - - r.userShellsMu.Lock() - defer r.userShellsMu.Unlock() - - r.userShells[key] = &userShellEntry{ - ProcessID: "", // No process yet - will be started when WebSocket connects - Label: label, - InitialCommand: initialCommand, - Closable: true, // Script terminals are always closable - CreatedAt: time.Now().UTC(), - } -} - -// StartUserShell starts or returns an existing user shell for a terminal tab. -// Each terminal tab gets its own independent shell process. -// If opts.InitialCommand is provided, it will be written to stdin after the shell starts. -func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, terminalID, workingDir, preferredShell string, opts *UserShellOptions) (*InteractiveProcessInfo, error) { - key := sessionID + ":" + terminalID - - // Normalize options - if opts == nil { - opts = &UserShellOptions{} - } - if opts.Label == "" { - opts.Label = "Terminal" - } - - // Check if shell entry already exists (auto-created by ListUserShells or RegisterScriptShell) - var existingEntry *userShellEntry - r.userShellsMu.RLock() - entry, exists := r.userShells[key] - if exists { - // If entry has a process, check if it's still alive - if entry.ProcessID != "" { - if info, ok := r.Get(entry.ProcessID, false); ok { - r.userShellsMu.RUnlock() - return info, nil - } - // Process died - we'll start a new one below - } - // Entry exists but no process (pre-registered) or process died - // Keep the existing metadata (label, closable, createdAt, initialCommand) - existingEntry = entry - } - r.userShellsMu.RUnlock() - - // Use initial command from pre-registered entry if not provided in opts - initialCommand := opts.InitialCommand - if initialCommand == "" && existingEntry != nil { - initialCommand = existingEntry.InitialCommand - } - - req := InteractiveStartRequest{ - SessionID: sessionID, - Command: defaultShellCommand(preferredShell), - WorkingDir: workingDir, - InitialCommand: initialCommand, - DisableTurnDetection: true, // User shells must not trigger turn complete / MarkReady - IsUserShell: true, // Exclude from session-level lookups (ResizeBySession, GetPtyWriterBySession) - } - - info, err := r.Start(ctx, req) - if err != nil { - return nil, err - } - - // Track the user shell with metadata - // If entry already exists (auto-created by ListUserShells), preserve its metadata - r.userShellsMu.Lock() - if existingEntry != nil { - // Update existing entry with the new process ID - existingEntry.ProcessID = info.ID - r.userShells[key] = existingEntry - } else { - // Create new entry - closable := true // Default: closable - if opts.Closable != nil { - closable = *opts.Closable - } - r.userShells[key] = &userShellEntry{ - ProcessID: info.ID, - Label: opts.Label, - InitialCommand: opts.InitialCommand, - Closable: closable, - CreatedAt: time.Now().UTC(), - } - } - r.userShellsMu.Unlock() - - r.logger.Info("started user shell", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.String("process_id", info.ID), - zap.String("shell", req.Command[0]), - zap.String("working_dir", workingDir), - zap.String("label", opts.Label), - zap.String("initial_command", opts.InitialCommand)) - - return info, nil -} - -// UserShellInfo contains information about a running user shell. -type UserShellInfo struct { - TerminalID string `json:"terminal_id"` - ProcessID string `json:"process_id"` - Running bool `json:"running"` - Label string `json:"label"` // Display name (e.g., "Terminal" or "Terminal 2") - Closable bool `json:"closable"` // Whether the terminal can be closed - InitialCommand string `json:"initial_command"` // Command that was run (empty for plain shells) - CreatedAt time.Time `json:"created_at"` // When the shell was created (for stable ordering) -} - -// ListUserShells returns all user shells for a session, sorted by creation time. -// If no plain shell terminals exist, automatically creates the first "Terminal" entry. -func (r *InteractiveRunner) ListUserShells(sessionID string) []UserShellInfo { - r.userShellsMu.Lock() - defer r.userShellsMu.Unlock() - - prefix := sessionID + ":" - var shells []UserShellInfo - hasPlainShell := false - - for key, entry := range r.userShells { - if strings.HasPrefix(key, prefix) { - terminalID := key[len(prefix):] - // Check if process is still alive - _, running := r.Get(entry.ProcessID, false) - shells = append(shells, UserShellInfo{ - TerminalID: terminalID, - ProcessID: entry.ProcessID, - Running: running, - Label: entry.Label, - Closable: entry.Closable, - InitialCommand: entry.InitialCommand, - CreatedAt: entry.CreatedAt, - }) - // Check if this is a plain shell (not a script terminal) - if entry.InitialCommand == "" { - hasPlainShell = true - } - } - } - - // Auto-create the first "Terminal" if no plain shells exist - if !hasPlainShell { - terminalID := "shell-" + uuid.New().String() - now := time.Now().UTC() - entry := &userShellEntry{ - ProcessID: "", // No process yet - will be started when WebSocket connects - Label: "Terminal", - InitialCommand: "", - Closable: false, // First terminal is not closable - CreatedAt: now, - } - r.userShells[prefix+terminalID] = entry - - shells = append(shells, UserShellInfo{ - TerminalID: terminalID, - ProcessID: "", - Running: false, - Label: "Terminal", - Closable: false, - InitialCommand: "", - CreatedAt: now, - }) - } - - // Sort by creation time for stable ordering - sort.Slice(shells, func(i, j int) bool { - return shells[i].CreatedAt.Before(shells[j].CreatedAt) - }) - - return shells -} - -// StopUserShell stops a user shell for a terminal tab. -func (r *InteractiveRunner) StopUserShell(ctx context.Context, sessionID, terminalID string) error { - key := sessionID + ":" + terminalID - - r.userShellsMu.Lock() - entry, exists := r.userShells[key] - if exists { - delete(r.userShells, key) - } - r.userShellsMu.Unlock() - - if !exists { - return nil - } - - r.logger.Info("stopping user shell", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.String("process_id", entry.ProcessID)) - - return r.Stop(ctx, entry.ProcessID) -} - -// ResizeUserShell resizes the PTY for a user shell. -func (r *InteractiveRunner) ResizeUserShell(sessionID, terminalID string, cols, rows uint16) error { - key := sessionID + ":" + terminalID - - r.userShellsMu.RLock() - entry, exists := r.userShells[key] - r.userShellsMu.RUnlock() - - if !exists { - return fmt.Errorf("no user shell found for session %s terminal %s", sessionID, terminalID) - } - - proc, ok := r.get(entry.ProcessID) - if !ok { - return fmt.Errorf("process not found: %s", entry.ProcessID) - } - - return r.lazyStartAndResize(proc, cols, rows, - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - ) -} - -// GetUserShellPtyWriter returns the PTY writer for a user shell. -func (r *InteractiveRunner) GetUserShellPtyWriter(sessionID, terminalID string) (io.Writer, string, error) { - key := sessionID + ":" + terminalID - - r.userShellsMu.RLock() - entry, exists := r.userShells[key] - r.userShellsMu.RUnlock() - - if !exists { - return nil, "", fmt.Errorf("no user shell found for session %s terminal %s", sessionID, terminalID) - } - - writer, err := r.GetPtyWriter(entry.ProcessID) - if err != nil { - return nil, entry.ProcessID, err - } - - return writer, entry.ProcessID, nil -} - -// ClearUserShellDirectOutput clears the direct output for a user shell. -func (r *InteractiveRunner) ClearUserShellDirectOutput(sessionID, terminalID string) { - key := sessionID + ":" + terminalID - - r.userShellsMu.RLock() - entry, exists := r.userShells[key] - r.userShellsMu.RUnlock() - - if !exists { - return - } - - proc, ok := r.get(entry.ProcessID) - if !ok { - return - } - - proc.directOutputMu.Lock() - proc.directOutput = nil - proc.hasActiveWebSocket = false - proc.directOutputMu.Unlock() - - r.logger.Info("direct output cleared for user shell", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID)) -} - -// containsDSRQuery checks if data contains a Device Status Report (cursor position) query. -// DSR query is: ESC [ 6 n or ESC [ ? 6 n -func containsDSRQuery(data []byte) bool { - return bytes.Contains(data, []byte("\x1b[6n")) || bytes.Contains(data, []byte("\x1b[?6n")) -} - -// containsDA1Query checks if data contains a Primary Device Attributes query. -// DA1 query is: ESC [ c or ESC [ 0 c (but NOT ESC [ c where digit is 1-9, -// since those are cursor forward sequences). -func containsDA1Query(data []byte) bool { - for i := 0; i+2 < len(data); i++ { - if data[i] != '\x1b' || data[i+1] != '[' { - continue - } - // ESC [ c — DA1 with no parameter - if data[i+2] == 'c' { - return true - } - // ESC [ 0 c — DA1 with explicit 0 parameter - if data[i+2] == '0' && i+3 < len(data) && data[i+3] == 'c' { - return true - } - // ESC [ <1-9> c would be cursor forward — skip it - } - return false -} - diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner_test.go b/apps/backend/internal/agentctl/server/process/interactive_runner_test.go deleted file mode 100644 index 2aa83b5f0d..0000000000 --- a/apps/backend/internal/agentctl/server/process/interactive_runner_test.go +++ /dev/null @@ -1,1077 +0,0 @@ -package process - -import ( - "context" - "strings" - "sync" - "testing" - "time" - - "github.com/kandev/kandev/internal/agentctl/types" -) - -func TestInteractiveRunner_Start(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - req := InteractiveStartRequest{ - SessionID: "test-session", - Command: []string{"echo", "hello"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - if info.ID == "" { - t.Error("Start() returned empty ID") - } - if info.SessionID != "test-session" { - t.Errorf("Start() SessionID = %q, want %q", info.SessionID, "test-session") - } - if info.Status != types.ProcessStatusRunning { - t.Errorf("Start() Status = %v, want %v", info.Status, types.ProcessStatusRunning) - } - - // Wait for process to exit - time.Sleep(500 * time.Millisecond) - - // Process should have completed - procInfo, ok := runner.Get(info.ID, false) - if !ok { - // Process may have been removed after exit, which is expected - return - } - if procInfo.Status == types.ProcessStatusRunning { - t.Error("Process should have exited") - } -} - -func TestInteractiveRunner_Start_ValidationErrors(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - tests := []struct { - name string - req InteractiveStartRequest - wantErr bool - }{ - { - name: "missing session_id", - req: InteractiveStartRequest{ - Command: []string{"echo"}, - }, - wantErr: true, - }, - { - name: "missing command", - req: InteractiveStartRequest{ - SessionID: "test", - }, - wantErr: true, - }, - { - name: "valid request", - req: InteractiveStartRequest{ - SessionID: "test", - Command: []string{"echo"}, - ImmediateStart: true, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := runner.Start(context.Background(), tt.req) - if (err != nil) != tt.wantErr { - t.Errorf("Start() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestInteractiveRunner_DeferredStart(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Start without ImmediateStart - process should be deferred - // Use 'cat' which blocks waiting for input, giving us time to check status - req := InteractiveStartRequest{ - SessionID: "deferred-session", - Command: []string{"cat"}, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Try to write - should fail because process not started - err = runner.WriteStdin(info.ID, "test") - if err == nil { - t.Error("WriteStdin() should fail for deferred process") - } - - // Trigger start via resize - err = runner.ResizeBySession("deferred-session", 80, 24) - if err != nil { - t.Fatalf("ResizeBySession() error = %v", err) - } - - // Give process time to start - time.Sleep(100 * time.Millisecond) - - // Now get process info - process should exist and be running - procInfo, ok := runner.GetBySession("deferred-session") - if !ok { - t.Fatal("GetBySession() should find process after resize") - } - if procInfo.Status != types.ProcessStatusRunning { - t.Errorf("Process status = %v, want running", procInfo.Status) - } - - // Clean up - stop the process - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = runner.Stop(ctx, info.ID) -} - -func TestInteractiveRunner_WriteStdin(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Start cat process that echoes input - req := InteractiveStartRequest{ - SessionID: "stdin-test", - Command: []string{"cat"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Give process time to start - time.Sleep(100 * time.Millisecond) - - // Write to stdin - err = runner.WriteStdin(info.ID, "hello\n") - if err != nil { - t.Errorf("WriteStdin() error = %v", err) - } - - // Stop the process - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = runner.Stop(ctx, info.ID) -} - -func TestInteractiveRunner_Stop(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Start a long-running process - req := InteractiveStartRequest{ - SessionID: "stop-test", - Command: []string{"sleep", "60"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Give process time to start - time.Sleep(100 * time.Millisecond) - - // Stop the process - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err = runner.Stop(ctx, info.ID) - if err != nil { - t.Errorf("Stop() error = %v", err) - } - - // Process should be removed after stop - time.Sleep(200 * time.Millisecond) - _, ok := runner.Get(info.ID, false) - if ok { - t.Error("Process should be removed after stop") - } -} - -func TestInteractiveRunner_GetBuffer(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - req := InteractiveStartRequest{ - SessionID: "buffer-test", - Command: []string{"echo", "buffered output"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Wait for output - time.Sleep(500 * time.Millisecond) - - buffer, ok := runner.GetBuffer(info.ID) - if !ok { - // Process may have exited and been removed - return - } - - // Check if output was captured - combined := "" - for _, chunk := range buffer { - combined += chunk.Data - } - - if !strings.Contains(combined, "buffered") { - t.Logf("Buffer contents: %q", combined) - // Note: Output might be empty if process exited too quickly - } -} - -func TestInteractiveRunner_Callbacks(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - var statusReceived bool - var mu sync.Mutex - - runner.SetOutputCallback(func(output *types.ProcessOutput) { - // Output callback received - }) - - runner.SetStatusCallback(func(status *types.ProcessStatusUpdate) { - mu.Lock() - statusReceived = true - mu.Unlock() - }) - - req := InteractiveStartRequest{ - SessionID: "callback-test", - Command: []string{"echo", "callback test"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - _, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Wait for callbacks - time.Sleep(500 * time.Millisecond) - - mu.Lock() - if !statusReceived { - t.Error("Status callback should have been called") - } - // Output callback may or may not be called depending on timing - mu.Unlock() -} - -func TestInteractiveRunner_TurnCompleteCallback(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - var turnCompleteCalled bool - var turnSessionID string - var mu sync.Mutex - - runner.SetTurnCompleteCallback(func(sessionID string) { - mu.Lock() - turnCompleteCalled = true - turnSessionID = sessionID - mu.Unlock() - }) - - // Start with a prompt pattern that matches "$ " - req := InteractiveStartRequest{ - SessionID: "turn-test", - Command: []string{"bash", "-c", "echo '$ '"}, - PromptPattern: `\$ $`, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - _, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Wait for turn detection - time.Sleep(500 * time.Millisecond) - - mu.Lock() - if turnCompleteCalled && turnSessionID != "turn-test" { - t.Errorf("Turn complete callback received wrong session ID: %q", turnSessionID) - } - mu.Unlock() -} - -func TestInteractiveRunner_DirectOutput(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - req := InteractiveStartRequest{ - SessionID: "direct-output-test", - Command: []string{"echo", "direct"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Create a mock direct writer - writer := &mockDirectWriter{} - - // Set direct output - err = runner.SetDirectOutput(info.ID, writer) - if err != nil { - t.Errorf("SetDirectOutput() error = %v", err) - } - - // Wait for output - time.Sleep(200 * time.Millisecond) - - // Clear direct output - err = runner.ClearDirectOutput(info.ID) - // May fail if process already exited, that's OK - _ = err - - // Check if writer received data - writer.mu.Lock() - gotData := len(writer.data) > 0 - writer.mu.Unlock() - - if gotData { - t.Log("Direct writer received data") - } -} - -// mockDirectWriter implements DirectOutputWriter for testing -type mockDirectWriter struct { - mu sync.Mutex - data []byte - closed bool -} - -func (w *mockDirectWriter) Write(p []byte) (n int, err error) { - w.mu.Lock() - defer w.mu.Unlock() - w.data = append(w.data, p...) - return len(p), nil -} - -func (w *mockDirectWriter) Close() error { - w.mu.Lock() - defer w.mu.Unlock() - w.closed = true - return nil -} - -func TestInteractiveRunner_GetPtyWriter(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - req := InteractiveStartRequest{ - SessionID: "pty-writer-test", - Command: []string{"cat"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Give process time to start - time.Sleep(100 * time.Millisecond) - - // Get PTY writer - writer, err := runner.GetPtyWriter(info.ID) - if err != nil { - t.Fatalf("GetPtyWriter() error = %v", err) - } - - if writer == nil { - t.Error("GetPtyWriter() returned nil writer") - } - - // Stop the process - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = runner.Stop(ctx, info.ID) -} - -func TestInteractiveRunner_GetPtyWriter_NotStarted(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Start without ImmediateStart - req := InteractiveStartRequest{ - SessionID: "not-started", - Command: []string{"cat"}, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Try to get PTY writer before process starts - _, err = runner.GetPtyWriter(info.ID) - if err == nil { - t.Error("GetPtyWriter() should fail for deferred process") - } -} - -func TestInteractiveRunner_NotFound(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Test various methods with non-existent process - _, ok := runner.Get("nonexistent", false) - if ok { - t.Error("Get() should return false for nonexistent process") - } - - _, ok = runner.GetBySession("nonexistent") - if ok { - t.Error("GetBySession() should return false for nonexistent session") - } - - _, ok = runner.GetBuffer("nonexistent") - if ok { - t.Error("GetBuffer() should return false for nonexistent process") - } - - err := runner.WriteStdin("nonexistent", "data") - if err == nil { - t.Error("WriteStdin() should fail for nonexistent process") - } - - ctx := context.Background() - err = runner.Stop(ctx, "nonexistent") - if err == nil { - t.Error("Stop() should fail for nonexistent process") - } - - err = runner.SetDirectOutput("nonexistent", nil) - if err == nil { - t.Error("SetDirectOutput() should fail for nonexistent process") - } - - _, err = runner.GetPtyWriter("nonexistent") - if err == nil { - t.Error("GetPtyWriter() should fail for nonexistent process") - } -} - -func TestInteractiveRunner_IsProcessRunning(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Non-existent process - if runner.IsProcessRunning("nonexistent") { - t.Error("IsProcessRunning() should return false for nonexistent process") - } - - // Start a process - req := InteractiveStartRequest{ - SessionID: "running-test", - Command: []string{"sleep", "10"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Give process time to start - time.Sleep(100 * time.Millisecond) - - // Process should be running - if !runner.IsProcessRunning(info.ID) { - t.Error("IsProcessRunning() should return true for running process") - } - - // Stop the process - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = runner.Stop(ctx, info.ID) - - // Give it time to clean up - time.Sleep(200 * time.Millisecond) - - // Process should no longer be running - if runner.IsProcessRunning(info.ID) { - t.Error("IsProcessRunning() should return false after stop") - } -} - -func TestInteractiveRunner_IsProcessReadyOrPending(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Non-existent process - if runner.IsProcessReadyOrPending("nonexistent") { - t.Error("IsProcessReadyOrPending() should return false for nonexistent process") - } - - // Start a deferred process (not started yet) - req := InteractiveStartRequest{ - SessionID: "pending-test", - Command: []string{"cat"}, - // ImmediateStart: false (deferred) - } - - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Deferred process should be "ready or pending" - if !runner.IsProcessReadyOrPending(info.ID) { - t.Error("IsProcessReadyOrPending() should return true for pending process") - } - - // But not "running" yet - if runner.IsProcessRunning(info.ID) { - t.Error("IsProcessRunning() should return false for pending process") - } - - // Trigger start via resize - err = runner.ResizeBySession("pending-test", 80, 24) - if err != nil { - t.Fatalf("ResizeBySession() error = %v", err) - } - - // Give process time to start - time.Sleep(100 * time.Millisecond) - - // Now it should be both "running" and "ready or pending" - if !runner.IsProcessRunning(info.ID) { - t.Error("IsProcessRunning() should return true after start") - } - if !runner.IsProcessReadyOrPending(info.ID) { - t.Error("IsProcessReadyOrPending() should return true for running process") - } - - // Stop the process - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = runner.Stop(ctx, info.ID) -} - -func TestContainsDSRQuery(t *testing.T) { - tests := []struct { - name string - data []byte - want bool - }{ - {"ESC[6n", []byte("\x1b[6n"), true}, - {"ESC[?6n", []byte("\x1b[?6n"), true}, - {"mixed content with DSR", []byte("text\x1b[6nmore"), true}, - {"no escape", []byte("hello world"), false}, - {"partial ESC[6", []byte("\x1b[6"), false}, - {"ESC[c is not DSR", []byte("\x1b[c"), false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := containsDSRQuery(tt.data); got != tt.want { - t.Errorf("containsDSRQuery(%q) = %v, want %v", tt.data, got, tt.want) - } - }) - } -} - -func TestContainsDA1Query(t *testing.T) { - tests := []struct { - name string - data []byte - want bool - }{ - {"ESC[c (DA1 no param)", []byte("\x1b[c"), true}, - {"ESC[0c (DA1 explicit 0)", []byte("\x1b[0c"), true}, - {"mixed with DA1", []byte("text\x1b[cmore"), true}, - {"both DSR and DA1", []byte("\x1b[6n\x1b[c"), true}, - {"no escape", []byte("hello world"), false}, - {"ESC[1c is cursor forward, not DA1", []byte("\x1b[1c"), false}, - {"ESC[2c is cursor forward, not DA1", []byte("\x1b[2c"), false}, - {"ESC[5c is cursor forward, not DA1", []byte("\x1b[5c"), false}, - {"ESC[9c is cursor forward, not DA1", []byte("\x1b[9c"), false}, - {"partial ESC[", []byte("\x1b["), false}, - {"empty data", []byte{}, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := containsDA1Query(tt.data); got != tt.want { - t.Errorf("containsDA1Query(%q) = %v, want %v", tt.data, got, tt.want) - } - }) - } -} - -func TestInteractiveRunner_ResizeByProcessID(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Start a deferred process - req := InteractiveStartRequest{ - SessionID: "resize-pid-test", - Command: []string{"cat"}, - } - info, err := runner.Start(context.Background(), req) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // Process should not be running yet - if runner.IsProcessRunning(info.ID) { - t.Error("process should not be running before resize") - } - - // Trigger start via ResizeByProcessID - if err := runner.ResizeByProcessID(info.ID, 80, 24); err != nil { - t.Fatalf("ResizeByProcessID() error = %v", err) - } - - // Give process time to start - time.Sleep(100 * time.Millisecond) - - if !runner.IsProcessRunning(info.ID) { - t.Error("process should be running after ResizeByProcessID") - } - - // Subsequent resize should succeed - if err := runner.ResizeByProcessID(info.ID, 120, 40); err != nil { - t.Errorf("second ResizeByProcessID() error = %v", err) - } - - // Non-existent process should fail - if err := runner.ResizeByProcessID("nonexistent", 80, 24); err == nil { - t.Error("ResizeByProcessID() should fail for nonexistent process") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = runner.Stop(ctx, info.ID) -} - -func TestInteractiveRunner_IsUserShellIsolation(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Start a regular (non-user-shell) process - agentReq := InteractiveStartRequest{ - SessionID: "isolation-test", - Command: []string{"cat"}, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - agentInfo, err := runner.Start(context.Background(), agentReq) - if err != nil { - t.Fatalf("Start() agent error = %v", err) - } - - // Start a user shell process for the same session - shellReq := InteractiveStartRequest{ - SessionID: "isolation-test", - Command: []string{"cat"}, - IsUserShell: true, - ImmediateStart: true, - DefaultCols: 80, - DefaultRows: 24, - } - shellInfo, err := runner.Start(context.Background(), shellReq) - if err != nil { - t.Fatalf("Start() shell error = %v", err) - } - - time.Sleep(100 * time.Millisecond) - - // ResizeBySession should target the agent process, not the user shell - if err := runner.ResizeBySession("isolation-test", 120, 40); err != nil { - t.Errorf("ResizeBySession() error = %v", err) - } - - // GetPtyWriterBySession should return the agent process, not the user shell - _, procID, err := runner.GetPtyWriterBySession("isolation-test") - if err != nil { - t.Fatalf("GetPtyWriterBySession() error = %v", err) - } - if procID != agentInfo.ID { - t.Errorf("GetPtyWriterBySession() returned process %q, want agent process %q", procID, agentInfo.ID) - } - - // SetDirectOutput on user shell should NOT track at session level - writer := &mockDirectWriter{} - if err := runner.SetDirectOutput(shellInfo.ID, writer); err != nil { - t.Fatalf("SetDirectOutput() shell error = %v", err) - } - // Session-level WebSocket should NOT be set for user shells - if runner.HasActiveWebSocketBySession("isolation-test") { - t.Error("user shell should not set session-level WebSocket tracking") - } - - // SetDirectOutput on agent process SHOULD track at session level - agentWriter := &mockDirectWriter{} - if err := runner.SetDirectOutput(agentInfo.ID, agentWriter); err != nil { - t.Fatalf("SetDirectOutput() agent error = %v", err) - } - if !runner.HasActiveWebSocketBySession("isolation-test") { - t.Error("agent process should set session-level WebSocket tracking") - } - - // ClearDirectOutputBySession should clear agent but not user shell - runner.ClearDirectOutputBySession("isolation-test") - - // Agent should have its direct output cleared - if runner.HasActiveWebSocket(agentInfo.ID) { - t.Error("agent WebSocket should be cleared after ClearDirectOutputBySession") - } - // User shell should still have its direct output - if !runner.HasActiveWebSocket(shellInfo.ID) { - t.Error("user shell WebSocket should NOT be cleared by ClearDirectOutputBySession") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = runner.Stop(ctx, agentInfo.ID) - _ = runner.Stop(ctx, shellInfo.ID) -} - -// --- User Shell Tests --- - -func TestInteractiveRunner_CreateUserShell_First(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - result := runner.CreateUserShell("session-1") - - if result.TerminalID == "" { - t.Error("CreateUserShell() returned empty TerminalID") - } - if !strings.HasPrefix(result.TerminalID, "shell-") { - t.Errorf("CreateUserShell() TerminalID = %q, want prefix 'shell-'", result.TerminalID) - } - if result.Label != "Terminal" { - t.Errorf("CreateUserShell() Label = %q, want 'Terminal'", result.Label) - } - if result.Closable { - t.Error("CreateUserShell() first terminal should not be closable") - } -} - -func TestInteractiveRunner_CreateUserShell_Subsequent(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - first := runner.CreateUserShell("session-1") - second := runner.CreateUserShell("session-1") - - if first.TerminalID == second.TerminalID { - t.Error("CreateUserShell() should return different terminal IDs") - } - if second.Label != "Terminal 2" { - t.Errorf("CreateUserShell() second Label = %q, want 'Terminal 2'", second.Label) - } - if !second.Closable { - t.Error("CreateUserShell() second terminal should be closable") - } - - third := runner.CreateUserShell("session-1") - if third.Label != "Terminal 3" { - t.Errorf("CreateUserShell() third Label = %q, want 'Terminal 3'", third.Label) - } - if !third.Closable { - t.Error("CreateUserShell() third terminal should be closable") - } -} - -func TestInteractiveRunner_CreateUserShell_DifferentSessions(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - r1 := runner.CreateUserShell("session-1") - r2 := runner.CreateUserShell("session-2") - - // Both should be "Terminal" (first in each session) - if r1.Label != "Terminal" { - t.Errorf("session-1 Label = %q, want 'Terminal'", r1.Label) - } - if r2.Label != "Terminal" { - t.Errorf("session-2 Label = %q, want 'Terminal'", r2.Label) - } - if r1.Closable || r2.Closable { - t.Error("first terminal in each session should not be closable") - } -} - -func TestInteractiveRunner_RegisterScriptShell(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - runner.RegisterScriptShell("session-1", "script-abc", "npm start", "npm run start") - - // Should appear in list - shells := runner.ListUserShells("session-1") - - // Should have 2: auto-created "Terminal" + registered script - if len(shells) != 2 { - t.Fatalf("ListUserShells() returned %d shells, want 2", len(shells)) - } - - // Find the script shell - var scriptShell *UserShellInfo - for i := range shells { - if shells[i].TerminalID == "script-abc" { - scriptShell = &shells[i] - break - } - } - if scriptShell == nil { - t.Fatal("script shell not found in list") - } else { - if scriptShell.Label != "npm start" { - t.Errorf("script shell Label = %q, want 'npm start'", scriptShell.Label) - } - if scriptShell.InitialCommand != "npm run start" { - t.Errorf("script shell InitialCommand = %q, want 'npm run start'", scriptShell.InitialCommand) - } - if !scriptShell.Closable { - t.Error("script shell should be closable") - } - } - if scriptShell.ProcessID != "" { - t.Errorf("script shell should have empty ProcessID before WebSocket connect, got %q", scriptShell.ProcessID) - } -} - -func TestInteractiveRunner_RegisterScriptShell_DoesNotAffectShellCount(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Register a script terminal - runner.RegisterScriptShell("session-1", "script-abc", "Build", "npm run build") - - // Create a plain shell - should be "Terminal" (first plain shell), not "Terminal 2" - result := runner.CreateUserShell("session-1") - if result.Label != "Terminal" { - t.Errorf("CreateUserShell() Label = %q, want 'Terminal' (scripts should not count)", result.Label) - } - if result.Closable { - t.Error("first plain shell should not be closable regardless of script terminals") - } -} - -func TestInteractiveRunner_ListUserShells_AutoCreatesFirst(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // First call should auto-create "Terminal" - shells := runner.ListUserShells("session-1") - - if len(shells) != 1 { - t.Fatalf("ListUserShells() returned %d shells, want 1", len(shells)) - } - if shells[0].Label != "Terminal" { - t.Errorf("auto-created shell Label = %q, want 'Terminal'", shells[0].Label) - } - if shells[0].Closable { - t.Error("auto-created first shell should not be closable") - } - if shells[0].Running { - t.Error("auto-created shell should not be running (no process)") - } - if !strings.HasPrefix(shells[0].TerminalID, "shell-") { - t.Errorf("auto-created shell TerminalID = %q, want prefix 'shell-'", shells[0].TerminalID) - } -} - -func TestInteractiveRunner_ListUserShells_StableAfterAutoCreate(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // First call auto-creates - shells1 := runner.ListUserShells("session-1") - // Second call should return the same list (not create another) - shells2 := runner.ListUserShells("session-1") - - if len(shells1) != 1 || len(shells2) != 1 { - t.Fatalf("ListUserShells() should return 1 shell each time, got %d and %d", len(shells1), len(shells2)) - } - if shells1[0].TerminalID != shells2[0].TerminalID { - t.Error("ListUserShells() should return the same terminal ID across calls") - } -} - -func TestInteractiveRunner_ListUserShells_Sorted(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Create shells with a small time gap - runner.CreateUserShell("session-1") - time.Sleep(10 * time.Millisecond) - runner.CreateUserShell("session-1") - time.Sleep(10 * time.Millisecond) - runner.RegisterScriptShell("session-1", "script-1", "Build", "make build") - - shells := runner.ListUserShells("session-1") - if len(shells) != 3 { - t.Fatalf("ListUserShells() returned %d shells, want 3", len(shells)) - } - - // Should be sorted by creation time - for i := 1; i < len(shells); i++ { - if shells[i].CreatedAt.Before(shells[i-1].CreatedAt) { - t.Errorf("shells not sorted by creation time: shell[%d] (%v) before shell[%d] (%v)", - i, shells[i].CreatedAt, i-1, shells[i-1].CreatedAt) - } - } -} - -func TestInteractiveRunner_ListUserShells_IsolatedBySessions(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - runner.CreateUserShell("session-1") - runner.CreateUserShell("session-1") - runner.CreateUserShell("session-2") - - shells1 := runner.ListUserShells("session-1") - shells2 := runner.ListUserShells("session-2") - - if len(shells1) != 2 { - t.Errorf("session-1 should have 2 shells, got %d", len(shells1)) - } - if len(shells2) != 1 { - t.Errorf("session-2 should have 1 shell, got %d", len(shells2)) - } -} - -func TestInteractiveRunner_StopUserShell(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Create a shell - result := runner.CreateUserShell("session-1") - - // Verify it's in the list - shells := runner.ListUserShells("session-1") - found := false - for _, s := range shells { - if s.TerminalID == result.TerminalID { - found = true - break - } - } - if !found { - t.Fatal("created shell not found in list") - } - - // Stop the shell (no process running, so this should remove the entry) - ctx := context.Background() - err := runner.StopUserShell(ctx, "session-1", result.TerminalID) - // Error is expected since there's no process to stop - _ = err - - // Shell should be removed from tracking - shells = runner.ListUserShells("session-1") - for _, s := range shells { - if s.TerminalID == result.TerminalID { - t.Error("stopped shell should be removed from list") - } - } -} - -func TestInteractiveRunner_StopUserShell_NonExistent(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Stopping a non-existent shell should not error - ctx := context.Background() - err := runner.StopUserShell(ctx, "session-1", "nonexistent") - if err != nil { - t.Errorf("StopUserShell() for non-existent shell should return nil, got %v", err) - } -} - -func TestInteractiveRunner_StopUserShell_ScriptTerminal(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Register and then stop a script terminal - runner.RegisterScriptShell("session-1", "script-abc", "Build", "npm run build") - - ctx := context.Background() - err := runner.StopUserShell(ctx, "session-1", "script-abc") - _ = err // Error expected since no process - - // Script should be removed from list (auto-created "Terminal" may still appear) - shells := runner.ListUserShells("session-1") - for _, s := range shells { - if s.TerminalID == "script-abc" { - t.Error("stopped script terminal should be removed from list") - } - } -} - -func TestInteractiveRunner_CreateUserShell_AtomicRegistration(t *testing.T) { - log := newTestLogger(t) - runner := NewInteractiveRunner(nil, log, 2*1024*1024) - - // Create a shell - it should be immediately visible in ListUserShells - result := runner.CreateUserShell("session-1") - - shells := runner.ListUserShells("session-1") - found := false - for _, s := range shells { - if s.TerminalID == result.TerminalID { - found = true - if s.Label != result.Label { - t.Errorf("shell Label mismatch: list=%q, create=%q", s.Label, result.Label) - } - if s.Closable != result.Closable { - t.Errorf("shell Closable mismatch: list=%v, create=%v", s.Closable, result.Closable) - } - break - } - } - if !found { - t.Error("CreateUserShell() result should be immediately visible in ListUserShells()") - } -} diff --git a/apps/backend/internal/agentctl/server/process/interactive_shells.go b/apps/backend/internal/agentctl/server/process/interactive_shells.go new file mode 100644 index 0000000000..1c56d4ecaf --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/interactive_shells.go @@ -0,0 +1,349 @@ +package process + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// UserShellOptions contains optional parameters for starting a user shell. +type UserShellOptions struct { + Label string // Display name (e.g., "Terminal" or script name) + InitialCommand string // Command to run after shell starts + Closable *bool // Whether the terminal can be closed (nil = auto-determine) +} + +// CreateUserShellResult contains the result of creating a new user shell. +type CreateUserShellResult struct { + TerminalID string `json:"terminal_id"` + Label string `json:"label"` + Closable bool `json:"closable"` +} + +// UserShellInfo contains information about a running user shell. +type UserShellInfo struct { + TerminalID string `json:"terminal_id"` + ProcessID string `json:"process_id"` + Running bool `json:"running"` + Label string `json:"label"` // Display name (e.g., "Terminal" or "Terminal 2") + Closable bool `json:"closable"` // Whether the terminal can be closed + InitialCommand string `json:"initial_command"` // Command that was run (empty for plain shells) + CreatedAt time.Time `json:"created_at"` // When the shell was created (for stable ordering) +} + +// CreateUserShell creates a new user shell terminal with auto-assigned ID and label. +// The first shell for a session is labeled "Terminal" and is not closable. +// Subsequent shells are labeled "Terminal 2", "Terminal 3", etc. and are closable. +// The entry is registered atomically to prevent races with ListUserShells. +func (r *InteractiveRunner) CreateUserShell(sessionID string) CreateUserShellResult { + r.userShellsMu.Lock() + defer r.userShellsMu.Unlock() + + // Count existing plain shell terminals for this session + prefix := sessionID + ":" + shellCount := 0 + for key, entry := range r.userShells { + if strings.HasPrefix(key, prefix) { + terminalID := key[len(prefix):] + // Only count plain shells (not script terminals) + if strings.HasPrefix(terminalID, "shell-") && entry.InitialCommand == "" { + shellCount++ + } + } + } + + // Generate terminal ID and label + terminalID := "shell-" + uuid.New().String() + + var label string + var closable bool + if shellCount == 0 { + label = "Terminal" + closable = false // First terminal is not closable + } else { + label = fmt.Sprintf("Terminal %d", shellCount+1) + closable = true + } + + // Register the entry so ListUserShells includes it immediately + r.userShells[prefix+terminalID] = &userShellEntry{ + ProcessID: "", // No process yet - will be started when WebSocket connects + Label: label, + InitialCommand: "", + Closable: closable, + CreatedAt: time.Now().UTC(), + } + + return CreateUserShellResult{ + TerminalID: terminalID, + Label: label, + Closable: closable, + } +} + +// RegisterScriptShell registers a script terminal entry so ListUserShells returns it. +// The actual process is not started until the WebSocket connects (StartUserShell handles that). +func (r *InteractiveRunner) RegisterScriptShell(sessionID, terminalID, label, initialCommand string) { + key := sessionID + ":" + terminalID + + r.userShellsMu.Lock() + defer r.userShellsMu.Unlock() + + r.userShells[key] = &userShellEntry{ + ProcessID: "", // No process yet - will be started when WebSocket connects + Label: label, + InitialCommand: initialCommand, + Closable: true, // Script terminals are always closable + CreatedAt: time.Now().UTC(), + } +} + +// StartUserShell starts or returns an existing user shell for a terminal tab. +// Each terminal tab gets its own independent shell process. +// If opts.InitialCommand is provided, it will be written to stdin after the shell starts. +func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, terminalID, workingDir, preferredShell string, opts *UserShellOptions) (*InteractiveProcessInfo, error) { + key := sessionID + ":" + terminalID + + // Normalize options + if opts == nil { + opts = &UserShellOptions{} + } + if opts.Label == "" { + opts.Label = "Terminal" + } + + // Check if shell entry already exists (auto-created by ListUserShells or RegisterScriptShell) + var existingEntry *userShellEntry + r.userShellsMu.RLock() + entry, exists := r.userShells[key] + if exists { + // If entry has a process, check if it's still alive + if entry.ProcessID != "" { + if info, ok := r.Get(entry.ProcessID, false); ok { + r.userShellsMu.RUnlock() + return info, nil + } + // Process died - we'll start a new one below + } + // Entry exists but no process (pre-registered) or process died + // Keep the existing metadata (label, closable, createdAt, initialCommand) + existingEntry = entry + } + r.userShellsMu.RUnlock() + + // Use initial command from pre-registered entry if not provided in opts + initialCommand := opts.InitialCommand + if initialCommand == "" && existingEntry != nil { + initialCommand = existingEntry.InitialCommand + } + + req := InteractiveStartRequest{ + SessionID: sessionID, + Command: defaultShellCommand(preferredShell), + WorkingDir: workingDir, + InitialCommand: initialCommand, + DisableTurnDetection: true, // User shells must not trigger turn complete / MarkReady + IsUserShell: true, // Exclude from session-level lookups (ResizeBySession, GetPtyWriterBySession) + } + + info, err := r.Start(ctx, req) + if err != nil { + return nil, err + } + + // Track the user shell with metadata + // If entry already exists (auto-created by ListUserShells), preserve its metadata + r.userShellsMu.Lock() + if existingEntry != nil { + // Update existing entry with the new process ID + existingEntry.ProcessID = info.ID + r.userShells[key] = existingEntry + } else { + // Create new entry + closable := true // Default: closable + if opts.Closable != nil { + closable = *opts.Closable + } + r.userShells[key] = &userShellEntry{ + ProcessID: info.ID, + Label: opts.Label, + InitialCommand: opts.InitialCommand, + Closable: closable, + CreatedAt: time.Now().UTC(), + } + } + r.userShellsMu.Unlock() + + r.logger.Info("started user shell", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("process_id", info.ID), + zap.String("shell", req.Command[0]), + zap.String("working_dir", workingDir), + zap.String("label", opts.Label), + zap.String("initial_command", opts.InitialCommand)) + + return info, nil +} + +// ListUserShells returns all user shells for a session, sorted by creation time. +// If no plain shell terminals exist, automatically creates the first "Terminal" entry. +func (r *InteractiveRunner) ListUserShells(sessionID string) []UserShellInfo { + r.userShellsMu.Lock() + defer r.userShellsMu.Unlock() + + prefix := sessionID + ":" + var shells []UserShellInfo + hasPlainShell := false + + for key, entry := range r.userShells { + if strings.HasPrefix(key, prefix) { + terminalID := key[len(prefix):] + // Check if process is still alive + _, running := r.Get(entry.ProcessID, false) + shells = append(shells, UserShellInfo{ + TerminalID: terminalID, + ProcessID: entry.ProcessID, + Running: running, + Label: entry.Label, + Closable: entry.Closable, + InitialCommand: entry.InitialCommand, + CreatedAt: entry.CreatedAt, + }) + // Check if this is a plain shell (not a script terminal) + if entry.InitialCommand == "" { + hasPlainShell = true + } + } + } + + // Auto-create the first "Terminal" if no plain shells exist + if !hasPlainShell { + terminalID := "shell-" + uuid.New().String() + now := time.Now().UTC() + entry := &userShellEntry{ + ProcessID: "", // No process yet - will be started when WebSocket connects + Label: "Terminal", + InitialCommand: "", + Closable: false, // First terminal is not closable + CreatedAt: now, + } + r.userShells[prefix+terminalID] = entry + + shells = append(shells, UserShellInfo{ + TerminalID: terminalID, + ProcessID: "", + Running: false, + Label: "Terminal", + Closable: false, + InitialCommand: "", + CreatedAt: now, + }) + } + + // Sort by creation time for stable ordering + sort.Slice(shells, func(i, j int) bool { + return shells[i].CreatedAt.Before(shells[j].CreatedAt) + }) + + return shells +} + +// StopUserShell stops a user shell for a terminal tab. +func (r *InteractiveRunner) StopUserShell(ctx context.Context, sessionID, terminalID string) error { + key := sessionID + ":" + terminalID + + r.userShellsMu.Lock() + entry, exists := r.userShells[key] + if exists { + delete(r.userShells, key) + } + r.userShellsMu.Unlock() + + if !exists { + return nil + } + + r.logger.Info("stopping user shell", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("process_id", entry.ProcessID)) + + return r.Stop(ctx, entry.ProcessID) +} + +// ResizeUserShell resizes the PTY for a user shell. +func (r *InteractiveRunner) ResizeUserShell(sessionID, terminalID string, cols, rows uint16) error { + key := sessionID + ":" + terminalID + + r.userShellsMu.RLock() + entry, exists := r.userShells[key] + r.userShellsMu.RUnlock() + + if !exists { + return fmt.Errorf("no user shell found for session %s terminal %s", sessionID, terminalID) + } + + proc, ok := r.get(entry.ProcessID) + if !ok { + return fmt.Errorf("process not found: %s", entry.ProcessID) + } + + return r.lazyStartAndResize(proc, cols, rows, + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + ) +} + +// GetUserShellPtyWriter returns the PTY writer for a user shell. +func (r *InteractiveRunner) GetUserShellPtyWriter(sessionID, terminalID string) (io.Writer, string, error) { + key := sessionID + ":" + terminalID + + r.userShellsMu.RLock() + entry, exists := r.userShells[key] + r.userShellsMu.RUnlock() + + if !exists { + return nil, "", fmt.Errorf("no user shell found for session %s terminal %s", sessionID, terminalID) + } + + writer, err := r.GetPtyWriter(entry.ProcessID) + if err != nil { + return nil, entry.ProcessID, err + } + + return writer, entry.ProcessID, nil +} + +// ClearUserShellDirectOutput clears the direct output for a user shell. +func (r *InteractiveRunner) ClearUserShellDirectOutput(sessionID, terminalID string) { + key := sessionID + ":" + terminalID + + r.userShellsMu.RLock() + entry, exists := r.userShells[key] + r.userShellsMu.RUnlock() + + if !exists { + return + } + + proc, ok := r.get(entry.ProcessID) + if !ok { + return + } + + proc.directOutputMu.Lock() + proc.directOutput = nil + proc.hasActiveWebSocket = false + proc.directOutputMu.Unlock() + + r.logger.Info("direct output cleared for user shell", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID)) +} diff --git a/apps/backend/internal/agentctl/server/process/interactive_terminal.go b/apps/backend/internal/agentctl/server/process/interactive_terminal.go new file mode 100644 index 0000000000..1346216061 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/interactive_terminal.go @@ -0,0 +1,30 @@ +package process + +import "bytes" + +// containsDSRQuery checks if data contains a Device Status Report (cursor position) query. +// DSR query is: ESC [ 6 n or ESC [ ? 6 n +func containsDSRQuery(data []byte) bool { + return bytes.Contains(data, []byte("\x1b[6n")) || bytes.Contains(data, []byte("\x1b[?6n")) +} + +// containsDA1Query checks if data contains a Primary Device Attributes query. +// DA1 query is: ESC [ c or ESC [ 0 c (but NOT ESC [ c where digit is 1-9, +// since those are cursor forward sequences). +func containsDA1Query(data []byte) bool { + for i := 0; i+2 < len(data); i++ { + if data[i] != '\x1b' || data[i+1] != '[' { + continue + } + // ESC [ c — DA1 with no parameter + if data[i+2] == 'c' { + return true + } + // ESC [ 0 c — DA1 with explicit 0 parameter + if data[i+2] == '0' && i+3 < len(data) && data[i+3] == 'c' { + return true + } + // ESC [ <1-9> c would be cursor forward — skip it + } + return false +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 460a1f3cdc..9b002d7016 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -217,110 +217,27 @@ func (m *Manager) Start(ctx context.Context) error { m.exitCode.Store(-1) m.exitErr.Store(errorWrapper{err: nil}) - // Validate command if len(m.cfg.AgentArgs) == 0 { m.status.Store(StatusError) return fmt.Errorf("no agent command configured") } - // Build adapter config - mcpServers := make([]adapter.McpServerConfig, len(m.cfg.McpServers)) - for i, mcp := range m.cfg.McpServers { - mcpServers[i] = adapter.McpServerConfig{ - Name: mcp.Name, - URL: mcp.URL, - Type: mcp.Type, - Command: mcp.Command, - Args: mcp.Args, - } - } - m.adapterCfg = &adapter.Config{ - WorkDir: m.cfg.WorkDir, - AutoApprove: m.cfg.AutoApprovePermissions, - ApprovalPolicy: m.cfg.ApprovalPolicy, - McpServers: mcpServers, - AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code") - } - - // Log MCP server details for debugging - for i, srv := range mcpServers { - m.logger.Debug("MCP server config", - zap.Int("index", i), - zap.String("name", srv.Name), - zap.String("url", srv.URL), - zap.String("type", srv.Type), - zap.String("command", srv.Command)) - } - - // Create adapter before starting the process so we can call PrepareEnvironment and PrepareCommandArgs - if err := m.createAdapter(); err != nil { + // Build adapter config and create protocol adapter + if err := m.buildAdapterConfig(); err != nil { m.status.Store(StatusError) - return fmt.Errorf("failed to create adapter: %w", err) + return err } - // Prepare protocol-specific environment before starting the process - // This may return additional environment variables that need to be passed to the subprocess - adapterEnv, err := m.adapter.PrepareEnvironment() - if err != nil { - m.logger.Warn("failed to prepare protocol environment", zap.Error(err)) - } - // Merge adapter-provided env vars into the subprocess environment - for k, v := range adapterEnv { - m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v)) - } - - // Get extra command args from the adapter (e.g., -c flags for Codex MCP config) - extraArgs := m.adapter.PrepareCommandArgs() - - // Build final command args - cmdArgs := append(m.cfg.AgentArgs[1:], extraArgs...) - - // Store the full command string (binary + all args including adapter extras) - m.finalCommand = strings.Join(append([]string{m.cfg.AgentArgs[0]}, cmdArgs...), " ") - - m.logger.Debug("final agent command", - zap.String("binary", m.cfg.AgentArgs[0]), - zap.Strings("args", cmdArgs), - zap.Int("extra_args_count", len(extraArgs))) - - // NOTE: We intentionally don't use exec.CommandContext here because we don't want - // the HTTP request context to kill the agent process when the request completes. - m.cmd = exec.Command(m.cfg.AgentArgs[0], cmdArgs...) - m.cmd.Dir = m.cfg.WorkDir - m.cmd.Env = m.cfg.AgentEnv - // Create a new process group so we can kill all child processes together. - // This is important for adapters like OpenCode that spawn child processes - // (npx -> sh -> node -> opencode binary). - setProcGroup(m.cmd) - - // Set up pipes - m.stdin, err = m.cmd.StdinPipe() - if err != nil { + // Assemble final command and start the subprocess + if err := m.buildFinalCommand(); err != nil { m.status.Store(StatusError) - return fmt.Errorf("failed to create stdin pipe: %w", err) + return err } - m.stdout, err = m.cmd.StdoutPipe() - if err != nil { + // Set up stdin/stdout/stderr pipes + if err := m.startProcessPipes(); err != nil { m.status.Store(StatusError) - return fmt.Errorf("failed to create stdout pipe: %w", err) - } - - m.stderr, err = m.cmd.StderrPipe() - if err != nil { - m.status.Store(StatusError) - return fmt.Errorf("failed to create stderr pipe: %w", err) - } - - // Start the process - m.logger.Info("starting agent process", - zap.Strings("args", m.cfg.AgentArgs), - zap.Strings("extra_args", extraArgs), - zap.String("workdir", m.cfg.WorkDir), - zap.Int("env_count", len(m.cfg.AgentEnv))) - if err := m.cmd.Start(); err != nil { - m.status.Store(StatusError) - return fmt.Errorf("failed to start agent: %w", err) + return err } m.stopCh = make(chan struct{}) @@ -345,18 +262,7 @@ func (m *Manager) Start(ctx context.Context) error { m.workspaceTracker.Start(context.Background()) // Auto-create shell session if enabled - if m.cfg.ShellEnabled { - shellCfg := shell.DefaultConfig(m.cfg.WorkDir) - shellCfg.ShellCommand = preferredShellCommand(m.cfg.AgentEnv) - shellSession, err := shell.NewSession(shellCfg, m.logger) - if err != nil { - m.logger.Warn("failed to create shell session", zap.Error(err)) - // Non-fatal: agent can still work without shell - } else { - m.shell = shellSession - m.logger.Info("shell session auto-created") - } - } + m.startAgentShell() m.status.Store(StatusRunning) m.logger.Info("agent process started", zap.Int("pid", m.cmd.Process.Pid)) @@ -364,6 +270,125 @@ func (m *Manager) Start(ctx context.Context) error { return nil } +// buildAdapterConfig constructs the adapter configuration and initialises the +// protocol adapter, including merging any adapter-provided environment variables. +func (m *Manager) buildAdapterConfig() error { + mcpServers := make([]adapter.McpServerConfig, len(m.cfg.McpServers)) + for i, mcp := range m.cfg.McpServers { + mcpServers[i] = adapter.McpServerConfig{ + Name: mcp.Name, + URL: mcp.URL, + Type: mcp.Type, + Command: mcp.Command, + Args: mcp.Args, + } + } + m.adapterCfg = &adapter.Config{ + WorkDir: m.cfg.WorkDir, + AutoApprove: m.cfg.AutoApprovePermissions, + ApprovalPolicy: m.cfg.ApprovalPolicy, + McpServers: mcpServers, + AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code") + } + + for i, srv := range mcpServers { + m.logger.Debug("MCP server config", + zap.Int("index", i), + zap.String("name", srv.Name), + zap.String("url", srv.URL), + zap.String("type", srv.Type), + zap.String("command", srv.Command)) + } + + if err := m.createAdapter(); err != nil { + return fmt.Errorf("failed to create adapter: %w", err) + } + + // Merge adapter-provided environment variables into the subprocess environment + adapterEnv, err := m.adapter.PrepareEnvironment() + if err != nil { + m.logger.Warn("failed to prepare protocol environment", zap.Error(err)) + } + for k, v := range adapterEnv { + m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v)) + } + return nil +} + +// buildFinalCommand assembles the full command args, creates the exec.Cmd, and +// starts the subprocess. The process group is set so child processes can be killed together. +func (m *Manager) buildFinalCommand() error { + extraArgs := m.adapter.PrepareCommandArgs() + + cmdArgs := make([]string, 0, len(m.cfg.AgentArgs)-1+len(extraArgs)) + cmdArgs = append(cmdArgs, m.cfg.AgentArgs[1:]...) + cmdArgs = append(cmdArgs, extraArgs...) + + m.finalCommand = strings.Join(append([]string{m.cfg.AgentArgs[0]}, cmdArgs...), " ") + + m.logger.Debug("final agent command", + zap.String("binary", m.cfg.AgentArgs[0]), + zap.Strings("args", cmdArgs), + zap.Int("extra_args_count", len(extraArgs))) + + // NOTE: We intentionally don't use exec.CommandContext here because we don't want + // the HTTP request context to kill the agent process when the request completes. + m.cmd = exec.Command(m.cfg.AgentArgs[0], cmdArgs...) + m.cmd.Dir = m.cfg.WorkDir + m.cmd.Env = m.cfg.AgentEnv + // Create a new process group so we can kill all child processes together. + // This is important for adapters like OpenCode that spawn child processes + // (npx -> sh -> node -> opencode binary). + setProcGroup(m.cmd) + + m.logger.Info("starting agent process", + zap.Strings("args", m.cfg.AgentArgs), + zap.Strings("extra_args", extraArgs), + zap.String("workdir", m.cfg.WorkDir), + zap.Int("env_count", len(m.cfg.AgentEnv))) + + if err := m.cmd.Start(); err != nil { + return fmt.Errorf("failed to start agent: %w", err) + } + return nil +} + +// startProcessPipes creates stdin, stdout, and stderr pipes for the agent subprocess. +// The pipes must be created before the process starts. +func (m *Manager) startProcessPipes() error { + var err error + m.stdin, err = m.cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to create stdin pipe: %w", err) + } + m.stdout, err = m.cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + m.stderr, err = m.cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + return nil +} + +// startAgentShell auto-creates a shell session when ShellEnabled is configured. +// Failure is non-fatal: the agent can still work without a shell session. +func (m *Manager) startAgentShell() { + if !m.cfg.ShellEnabled { + return + } + shellCfg := shell.DefaultConfig(m.cfg.WorkDir) + shellCfg.ShellCommand = preferredShellCommand(m.cfg.AgentEnv) + shellSession, err := shell.NewSession(shellCfg, m.logger) + if err != nil { + m.logger.Warn("failed to create shell session", zap.Error(err)) + return + } + m.shell = shellSession + m.logger.Info("shell session auto-created") +} + func preferredShellCommand(env []string) string { if value := lookupEnvValue(env, "AGENTCTL_SHELL_COMMAND"); value != "" { return value @@ -512,7 +537,18 @@ func (m *Manager) Stop(ctx context.Context) error { m.logger.Info("stopping agent process - START") m.status.Store(StatusStopping) - // Stop shell session + m.stopShellAndProcesses(ctx) + m.closeAdapterAndStdin() + m.killProcessGroupIfRequired() + m.waitForProcessExit(ctx) + + m.status.Store(StatusStopped) + m.logger.Info("stopping agent process - COMPLETE") + return nil +} + +// stopShellAndProcesses stops the shell session, workspace processes, and workspace tracker. +func (m *Manager) stopShellAndProcesses(ctx context.Context) { m.logger.Debug("stopping shell session") if m.shell != nil { if err := m.shell.Stop(); err != nil { @@ -531,14 +567,15 @@ func (m *Manager) Stop(ctx context.Context) error { } m.logger.Debug("workspace processes stopped") - // Stop workspace tracker m.logger.Debug("stopping workspace tracker") if m.workspaceTracker != nil { m.workspaceTracker.Stop() } m.logger.Debug("workspace tracker stopped") +} - // Close the adapter +// closeAdapterAndStdin closes the protocol adapter, the stop channel, and stdin. +func (m *Manager) closeAdapterAndStdin() { m.logger.Debug("closing adapter") if m.adapter != nil { if err := m.adapter.Close(); err != nil { @@ -547,7 +584,6 @@ func (m *Manager) Stop(ctx context.Context) error { } m.logger.Debug("adapter closed") - // Close stop channel to signal readers m.logger.Debug("closing stop channel") if m.stopCh != nil { close(m.stopCh) @@ -562,27 +598,31 @@ func (m *Manager) Stop(ctx context.Context) error { } } m.logger.Debug("stdin closed") +} - // Some adapters (like OpenCode) run as HTTP servers and don't exit on stdin close. - // For these, we need to kill the process group immediately. +// killProcessGroupIfRequired kills the entire process group for adapters (such as +// OpenCode) that run as HTTP servers and do not exit when stdin is closed. +func (m *Manager) killProcessGroupIfRequired() { + if m.adapter == nil || !m.adapter.RequiresProcessKill() { + return + } + if m.cmd == nil || m.cmd.Process == nil { + return + } // We kill the process group to ensure all child processes are killed too. // This is important because OpenCode spawns: npx -> sh -> node -> opencode binary - if m.adapter != nil && m.adapter.RequiresProcessKill() { - if m.cmd != nil && m.cmd.Process != nil { - pid := m.cmd.Process.Pid - m.logger.Debug("killing process group", zap.Int("pgid", pid)) - // Kill the entire process group (platform-specific implementation) - if err := killProcessGroup(pid); err != nil { - m.logger.Debug("failed to kill process group, trying single process", zap.Error(err)) - // Fallback to killing just the process - if err := m.cmd.Process.Kill(); err != nil { - m.logger.Warn("failed to kill process", zap.Error(err)) - } - } + pid := m.cmd.Process.Pid + m.logger.Debug("killing process group", zap.Int("pgid", pid)) + if err := killProcessGroup(pid); err != nil { + m.logger.Debug("failed to kill process group, trying single process", zap.Error(err)) + if err := m.cmd.Process.Kill(); err != nil { + m.logger.Warn("failed to kill process", zap.Error(err)) } } +} - // Wait for process to exit with timeout +// waitForProcessExit waits for all goroutines to finish, force-killing on context timeout. +func (m *Manager) waitForProcessExit(ctx context.Context) { m.logger.Debug("waiting for process to exit") done := make(chan struct{}) go func() { @@ -594,7 +634,6 @@ func (m *Manager) Stop(ctx context.Context) error { case <-done: m.logger.Info("agent process stopped gracefully") case <-ctx.Done(): - // Force kill if m.cmd != nil && m.cmd.Process != nil { m.logger.Warn("force killing agent process") if err := m.cmd.Process.Kill(); err != nil { @@ -602,10 +641,6 @@ func (m *Manager) Stop(ctx context.Context) error { } } } - - m.status.Store(StatusStopped) - m.logger.Info("stopping agent process - COMPLETE") - return nil } // readStderr reads and logs stderr from the agent @@ -837,9 +872,7 @@ func (m *Manager) autoApprovePermission(req *adapter.PermissionRequest) (*adapte func (m *Manager) sendPermissionNotification(pending *PendingPermission) { // Convert options to streams.PermissionOption (types.PermissionOption is an alias) options := make([]streams.PermissionOption, len(pending.Request.Options)) - for i, opt := range pending.Request.Options { - options[i] = streams.PermissionOption(opt) - } + copy(options, pending.Request.Options) event := adapter.AgentEvent{ Type: adapter.EventTypePermissionRequest, diff --git a/apps/backend/internal/agentctl/server/process/runner.go b/apps/backend/internal/agentctl/server/process/runner.go index 01468b35e7..34b1429a9c 100644 --- a/apps/backend/internal/agentctl/server/process/runner.go +++ b/apps/backend/internal/agentctl/server/process/runner.go @@ -284,6 +284,17 @@ func (r *ProcessRunner) Start(ctx context.Context, req StartProcessRequest) (*Pr r.publishStatus(proc) + if err := r.startAndActivate(proc, cmd, id, stdout, stderr); err != nil { + return nil, err + } + + info := proc.snapshot(false) + return &info, nil +} + +// startAndActivate starts the command and transitions the process to running state. +// On failure, marks the process as failed and removes it from tracking. +func (r *ProcessRunner) startAndActivate(proc *commandProcess, cmd *exec.Cmd, id string, stdout, stderr io.ReadCloser) error { if err := cmd.Start(); err != nil { proc.mu.Lock() proc.info.Status = types.ProcessStatusFailed @@ -293,23 +304,17 @@ func (r *ProcessRunner) Start(ctx context.Context, req StartProcessRequest) (*Pr r.mu.Lock() delete(r.processes, id) r.mu.Unlock() - return nil, fmt.Errorf("failed to start process: %w", err) + return 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 + return nil } // Stop attempts to gracefully terminate a process, escalating to force-kill if needed. diff --git a/apps/backend/internal/agentctl/server/process/runner_lifecycle_test.go b/apps/backend/internal/agentctl/server/process/runner_lifecycle_test.go new file mode 100644 index 0000000000..b03bf775af --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/runner_lifecycle_test.go @@ -0,0 +1,211 @@ +package process + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/types" +) + +func TestInteractiveRunner_Start(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + req := InteractiveStartRequest{ + SessionID: "test-session", + Command: []string{"echo", "hello"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + if info.ID == "" { + t.Error("Start() returned empty ID") + } + if info.SessionID != "test-session" { + t.Errorf("Start() SessionID = %q, want %q", info.SessionID, "test-session") + } + if info.Status != types.ProcessStatusRunning { + t.Errorf("Start() Status = %v, want %v", info.Status, types.ProcessStatusRunning) + } + + // Wait for process to exit + time.Sleep(500 * time.Millisecond) + + // Process should have completed + procInfo, ok := runner.Get(info.ID, false) + if !ok { + // Process may have been removed after exit, which is expected + return + } + if procInfo.Status == types.ProcessStatusRunning { + t.Error("Process should have exited") + } +} + +func TestInteractiveRunner_Start_ValidationErrors(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + tests := []struct { + name string + req InteractiveStartRequest + wantErr bool + }{ + { + name: "missing session_id", + req: InteractiveStartRequest{ + Command: []string{"echo"}, + }, + wantErr: true, + }, + { + name: "missing command", + req: InteractiveStartRequest{ + SessionID: "test", + }, + wantErr: true, + }, + { + name: "valid request", + req: InteractiveStartRequest{ + SessionID: "test", + Command: []string{"echo"}, + ImmediateStart: true, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runner.Start(context.Background(), tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("Start() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestInteractiveRunner_DeferredStart(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start without ImmediateStart - process should be deferred + // Use 'cat' which blocks waiting for input, giving us time to check status + req := InteractiveStartRequest{ + SessionID: "deferred-session", + Command: []string{"cat"}, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Try to write - should fail because process not started + err = runner.WriteStdin(info.ID, "test") + if err == nil { + t.Error("WriteStdin() should fail for deferred process") + } + + // Trigger start via resize + err = runner.ResizeBySession("deferred-session", 80, 24) + if err != nil { + t.Fatalf("ResizeBySession() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Now get process info - process should exist and be running + procInfo, ok := runner.GetBySession("deferred-session") + if !ok { + t.Fatal("GetBySession() should find process after resize") + } + if procInfo.Status != types.ProcessStatusRunning { + t.Errorf("Process status = %v, want running", procInfo.Status) + } + + // Clean up - stop the process + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) +} + +func TestInteractiveRunner_WriteStdin(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start cat process that echoes input + req := InteractiveStartRequest{ + SessionID: "stdin-test", + Command: []string{"cat"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Write to stdin + err = runner.WriteStdin(info.ID, "hello\n") + if err != nil { + t.Errorf("WriteStdin() error = %v", err) + } + + // Stop the process + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) +} + +func TestInteractiveRunner_Stop(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start a long-running process + req := InteractiveStartRequest{ + SessionID: "stop-test", + Command: []string{"sleep", "60"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Stop the process + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = runner.Stop(ctx, info.ID) + if err != nil { + t.Errorf("Stop() error = %v", err) + } + + // Process should be removed after stop + time.Sleep(200 * time.Millisecond) + _, ok := runner.Get(info.ID, false) + if ok { + t.Error("Process should be removed after stop") + } +} diff --git a/apps/backend/internal/agentctl/server/process/runner_shells_test.go b/apps/backend/internal/agentctl/server/process/runner_shells_test.go new file mode 100644 index 0000000000..00c55324db --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/runner_shells_test.go @@ -0,0 +1,392 @@ +package process + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestInteractiveRunner_IsUserShellIsolation(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start a regular (non-user-shell) process + agentReq := InteractiveStartRequest{ + SessionID: "isolation-test", + Command: []string{"cat"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + agentInfo, err := runner.Start(context.Background(), agentReq) + if err != nil { + t.Fatalf("Start() agent error = %v", err) + } + + // Start a user shell process for the same session + shellReq := InteractiveStartRequest{ + SessionID: "isolation-test", + Command: []string{"cat"}, + IsUserShell: true, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + shellInfo, err := runner.Start(context.Background(), shellReq) + if err != nil { + t.Fatalf("Start() shell error = %v", err) + } + + time.Sleep(100 * time.Millisecond) + + // ResizeBySession should target the agent process, not the user shell + if err := runner.ResizeBySession("isolation-test", 120, 40); err != nil { + t.Errorf("ResizeBySession() error = %v", err) + } + + // GetPtyWriterBySession should return the agent process, not the user shell + _, procID, err := runner.GetPtyWriterBySession("isolation-test") + if err != nil { + t.Fatalf("GetPtyWriterBySession() error = %v", err) + } + if procID != agentInfo.ID { + t.Errorf("GetPtyWriterBySession() returned process %q, want agent process %q", procID, agentInfo.ID) + } + + // SetDirectOutput on user shell should NOT track at session level + writer := &mockDirectWriter{} + if err := runner.SetDirectOutput(shellInfo.ID, writer); err != nil { + t.Fatalf("SetDirectOutput() shell error = %v", err) + } + // Session-level WebSocket should NOT be set for user shells + if runner.HasActiveWebSocketBySession("isolation-test") { + t.Error("user shell should not set session-level WebSocket tracking") + } + + // SetDirectOutput on agent process SHOULD track at session level + agentWriter := &mockDirectWriter{} + if err := runner.SetDirectOutput(agentInfo.ID, agentWriter); err != nil { + t.Fatalf("SetDirectOutput() agent error = %v", err) + } + if !runner.HasActiveWebSocketBySession("isolation-test") { + t.Error("agent process should set session-level WebSocket tracking") + } + + // ClearDirectOutputBySession should clear agent but not user shell + runner.ClearDirectOutputBySession("isolation-test") + + // Agent should have its direct output cleared + if runner.HasActiveWebSocket(agentInfo.ID) { + t.Error("agent WebSocket should be cleared after ClearDirectOutputBySession") + } + // User shell should still have its direct output + if !runner.HasActiveWebSocket(shellInfo.ID) { + t.Error("user shell WebSocket should NOT be cleared by ClearDirectOutputBySession") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, agentInfo.ID) + _ = runner.Stop(ctx, shellInfo.ID) +} + +func TestInteractiveRunner_CreateUserShell_First(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + result := runner.CreateUserShell("session-1") + + if result.TerminalID == "" { + t.Error("CreateUserShell() returned empty TerminalID") + } + if !strings.HasPrefix(result.TerminalID, "shell-") { + t.Errorf("CreateUserShell() TerminalID = %q, want prefix 'shell-'", result.TerminalID) + } + if result.Label != "Terminal" { + t.Errorf("CreateUserShell() Label = %q, want 'Terminal'", result.Label) + } + if result.Closable { + t.Error("CreateUserShell() first terminal should not be closable") + } +} + +func TestInteractiveRunner_CreateUserShell_Subsequent(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + first := runner.CreateUserShell("session-1") + second := runner.CreateUserShell("session-1") + + if first.TerminalID == second.TerminalID { + t.Error("CreateUserShell() should return different terminal IDs") + } + if second.Label != "Terminal 2" { + t.Errorf("CreateUserShell() second Label = %q, want 'Terminal 2'", second.Label) + } + if !second.Closable { + t.Error("CreateUserShell() second terminal should be closable") + } + + third := runner.CreateUserShell("session-1") + if third.Label != "Terminal 3" { + t.Errorf("CreateUserShell() third Label = %q, want 'Terminal 3'", third.Label) + } + if !third.Closable { + t.Error("CreateUserShell() third terminal should be closable") + } +} + +func TestInteractiveRunner_CreateUserShell_DifferentSessions(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + r1 := runner.CreateUserShell("session-1") + r2 := runner.CreateUserShell("session-2") + + // Both should be "Terminal" (first in each session) + if r1.Label != "Terminal" { + t.Errorf("session-1 Label = %q, want 'Terminal'", r1.Label) + } + if r2.Label != "Terminal" { + t.Errorf("session-2 Label = %q, want 'Terminal'", r2.Label) + } + if r1.Closable || r2.Closable { + t.Error("first terminal in each session should not be closable") + } +} + +func TestInteractiveRunner_RegisterScriptShell(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + runner.RegisterScriptShell("session-1", "script-abc", "npm start", "npm run start") + + // Should appear in list + shells := runner.ListUserShells("session-1") + + // Should have 2: auto-created "Terminal" + registered script + if len(shells) != 2 { + t.Fatalf("ListUserShells() returned %d shells, want 2", len(shells)) + } + + // Find the script shell + var scriptShell *UserShellInfo + for i := range shells { + if shells[i].TerminalID == "script-abc" { + scriptShell = &shells[i] + break + } + } + if scriptShell == nil { + t.Fatal("script shell not found in list") + } else { + if scriptShell.Label != "npm start" { + t.Errorf("script shell Label = %q, want 'npm start'", scriptShell.Label) + } + if scriptShell.InitialCommand != "npm run start" { + t.Errorf("script shell InitialCommand = %q, want 'npm run start'", scriptShell.InitialCommand) + } + if !scriptShell.Closable { + t.Error("script shell should be closable") + } + } + if scriptShell.ProcessID != "" { + t.Errorf("script shell should have empty ProcessID before WebSocket connect, got %q", scriptShell.ProcessID) + } +} + +func TestInteractiveRunner_RegisterScriptShell_DoesNotAffectShellCount(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Register a script terminal + runner.RegisterScriptShell("session-1", "script-abc", "Build", "npm run build") + + // Create a plain shell - should be "Terminal" (first plain shell), not "Terminal 2" + result := runner.CreateUserShell("session-1") + if result.Label != "Terminal" { + t.Errorf("CreateUserShell() Label = %q, want 'Terminal' (scripts should not count)", result.Label) + } + if result.Closable { + t.Error("first plain shell should not be closable regardless of script terminals") + } +} + +func TestInteractiveRunner_ListUserShells_AutoCreatesFirst(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // First call should auto-create "Terminal" + shells := runner.ListUserShells("session-1") + + if len(shells) != 1 { + t.Fatalf("ListUserShells() returned %d shells, want 1", len(shells)) + } + if shells[0].Label != "Terminal" { + t.Errorf("auto-created shell Label = %q, want 'Terminal'", shells[0].Label) + } + if shells[0].Closable { + t.Error("auto-created first shell should not be closable") + } + if shells[0].Running { + t.Error("auto-created shell should not be running (no process)") + } + if !strings.HasPrefix(shells[0].TerminalID, "shell-") { + t.Errorf("auto-created shell TerminalID = %q, want prefix 'shell-'", shells[0].TerminalID) + } +} + +func TestInteractiveRunner_ListUserShells_StableAfterAutoCreate(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // First call auto-creates + shells1 := runner.ListUserShells("session-1") + // Second call should return the same list (not create another) + shells2 := runner.ListUserShells("session-1") + + if len(shells1) != 1 || len(shells2) != 1 { + t.Fatalf("ListUserShells() should return 1 shell each time, got %d and %d", len(shells1), len(shells2)) + } + if shells1[0].TerminalID != shells2[0].TerminalID { + t.Error("ListUserShells() should return the same terminal ID across calls") + } +} + +func TestInteractiveRunner_ListUserShells_Sorted(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Create shells with a small time gap + runner.CreateUserShell("session-1") + time.Sleep(10 * time.Millisecond) + runner.CreateUserShell("session-1") + time.Sleep(10 * time.Millisecond) + runner.RegisterScriptShell("session-1", "script-1", "Build", "make build") + + shells := runner.ListUserShells("session-1") + if len(shells) != 3 { + t.Fatalf("ListUserShells() returned %d shells, want 3", len(shells)) + } + + // Should be sorted by creation time + for i := 1; i < len(shells); i++ { + if shells[i].CreatedAt.Before(shells[i-1].CreatedAt) { + t.Errorf("shells not sorted by creation time: shell[%d] (%v) before shell[%d] (%v)", + i, shells[i].CreatedAt, i-1, shells[i-1].CreatedAt) + } + } +} + +func TestInteractiveRunner_ListUserShells_IsolatedBySessions(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + runner.CreateUserShell("session-1") + runner.CreateUserShell("session-1") + runner.CreateUserShell("session-2") + + shells1 := runner.ListUserShells("session-1") + shells2 := runner.ListUserShells("session-2") + + if len(shells1) != 2 { + t.Errorf("session-1 should have 2 shells, got %d", len(shells1)) + } + if len(shells2) != 1 { + t.Errorf("session-2 should have 1 shell, got %d", len(shells2)) + } +} + +func TestInteractiveRunner_StopUserShell(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Create a shell + result := runner.CreateUserShell("session-1") + + // Verify it's in the list + shells := runner.ListUserShells("session-1") + found := false + for _, s := range shells { + if s.TerminalID == result.TerminalID { + found = true + break + } + } + if !found { + t.Fatal("created shell not found in list") + } + + // Stop the shell (no process running, so this should remove the entry) + ctx := context.Background() + err := runner.StopUserShell(ctx, "session-1", result.TerminalID) + // Error is expected since there's no process to stop + _ = err + + // Shell should be removed from tracking + shells = runner.ListUserShells("session-1") + for _, s := range shells { + if s.TerminalID == result.TerminalID { + t.Error("stopped shell should be removed from list") + } + } +} + +func TestInteractiveRunner_StopUserShell_NonExistent(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Stopping a non-existent shell should not error + ctx := context.Background() + err := runner.StopUserShell(ctx, "session-1", "nonexistent") + if err != nil { + t.Errorf("StopUserShell() for non-existent shell should return nil, got %v", err) + } +} + +func TestInteractiveRunner_StopUserShell_ScriptTerminal(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Register and then stop a script terminal + runner.RegisterScriptShell("session-1", "script-abc", "Build", "npm run build") + + ctx := context.Background() + err := runner.StopUserShell(ctx, "session-1", "script-abc") + _ = err // Error expected since no process + + // Script should be removed from list (auto-created "Terminal" may still appear) + shells := runner.ListUserShells("session-1") + for _, s := range shells { + if s.TerminalID == "script-abc" { + t.Error("stopped script terminal should be removed from list") + } + } +} + +func TestInteractiveRunner_CreateUserShell_AtomicRegistration(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Create a shell - it should be immediately visible in ListUserShells + result := runner.CreateUserShell("session-1") + + shells := runner.ListUserShells("session-1") + found := false + for _, s := range shells { + if s.TerminalID == result.TerminalID { + found = true + if s.Label != result.Label { + t.Errorf("shell Label mismatch: list=%q, create=%q", s.Label, result.Label) + } + if s.Closable != result.Closable { + t.Errorf("shell Closable mismatch: list=%v, create=%v", s.Closable, result.Closable) + } + break + } + } + if !found { + t.Error("CreateUserShell() result should be immediately visible in ListUserShells()") + } +} diff --git a/apps/backend/internal/agentctl/server/process/runner_state_test.go b/apps/backend/internal/agentctl/server/process/runner_state_test.go new file mode 100644 index 0000000000..d11ee3da8d --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/runner_state_test.go @@ -0,0 +1,394 @@ +package process + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/types" +) + +func TestInteractiveRunner_GetBuffer(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + req := InteractiveStartRequest{ + SessionID: "buffer-test", + Command: []string{"echo", "buffered output"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Wait for output + time.Sleep(500 * time.Millisecond) + + buffer, ok := runner.GetBuffer(info.ID) + if !ok { + // Process may have exited and been removed + return + } + + // Check if output was captured + combined := "" + for _, chunk := range buffer { + combined += chunk.Data + } + + if !strings.Contains(combined, "buffered") { + t.Logf("Buffer contents: %q", combined) + // Note: Output might be empty if process exited too quickly + } +} + +func TestInteractiveRunner_Callbacks(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + var statusReceived bool + var mu sync.Mutex + + runner.SetOutputCallback(func(output *types.ProcessOutput) { + // Output callback received + }) + + runner.SetStatusCallback(func(status *types.ProcessStatusUpdate) { + mu.Lock() + statusReceived = true + mu.Unlock() + }) + + req := InteractiveStartRequest{ + SessionID: "callback-test", + Command: []string{"echo", "callback test"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + _, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Wait for callbacks + time.Sleep(500 * time.Millisecond) + + mu.Lock() + if !statusReceived { + t.Error("Status callback should have been called") + } + // Output callback may or may not be called depending on timing + mu.Unlock() +} + +func TestInteractiveRunner_TurnCompleteCallback(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + var turnCompleteCalled bool + var turnSessionID string + var mu sync.Mutex + + runner.SetTurnCompleteCallback(func(sessionID string) { + mu.Lock() + turnCompleteCalled = true + turnSessionID = sessionID + mu.Unlock() + }) + + // Start with a prompt pattern that matches "$ " + req := InteractiveStartRequest{ + SessionID: "turn-test", + Command: []string{"bash", "-c", "echo '$ '"}, + PromptPattern: `\$ $`, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + _, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Wait for turn detection + time.Sleep(500 * time.Millisecond) + + mu.Lock() + if turnCompleteCalled && turnSessionID != "turn-test" { + t.Errorf("Turn complete callback received wrong session ID: %q", turnSessionID) + } + mu.Unlock() +} + +func TestInteractiveRunner_DirectOutput(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + req := InteractiveStartRequest{ + SessionID: "direct-output-test", + Command: []string{"echo", "direct"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Create a mock direct writer + writer := &mockDirectWriter{} + + // Set direct output + err = runner.SetDirectOutput(info.ID, writer) + if err != nil { + t.Errorf("SetDirectOutput() error = %v", err) + } + + // Wait for output + time.Sleep(200 * time.Millisecond) + + // Clear direct output + err = runner.ClearDirectOutput(info.ID) + // May fail if process already exited, that's OK + _ = err + + // Check if writer received data + writer.mu.Lock() + gotData := len(writer.data) > 0 + writer.mu.Unlock() + + if gotData { + t.Log("Direct writer received data") + } +} + +// mockDirectWriter implements DirectOutputWriter for testing +type mockDirectWriter struct { + mu sync.Mutex + data []byte + closed bool +} + +func (w *mockDirectWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + w.data = append(w.data, p...) + return len(p), nil +} + +func (w *mockDirectWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + w.closed = true + return nil +} + +func TestInteractiveRunner_GetPtyWriter(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + req := InteractiveStartRequest{ + SessionID: "pty-writer-test", + Command: []string{"cat"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Get PTY writer + writer, err := runner.GetPtyWriter(info.ID) + if err != nil { + t.Fatalf("GetPtyWriter() error = %v", err) + } + + if writer == nil { + t.Error("GetPtyWriter() returned nil writer") + } + + // Stop the process + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) +} + +func TestInteractiveRunner_GetPtyWriter_NotStarted(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start without ImmediateStart + req := InteractiveStartRequest{ + SessionID: "not-started", + Command: []string{"cat"}, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Try to get PTY writer before process starts + _, err = runner.GetPtyWriter(info.ID) + if err == nil { + t.Error("GetPtyWriter() should fail for deferred process") + } +} + +func TestInteractiveRunner_NotFound(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Test various methods with non-existent process + _, ok := runner.Get("nonexistent", false) + if ok { + t.Error("Get() should return false for nonexistent process") + } + + _, ok = runner.GetBySession("nonexistent") + if ok { + t.Error("GetBySession() should return false for nonexistent session") + } + + _, ok = runner.GetBuffer("nonexistent") + if ok { + t.Error("GetBuffer() should return false for nonexistent process") + } + + err := runner.WriteStdin("nonexistent", "data") + if err == nil { + t.Error("WriteStdin() should fail for nonexistent process") + } + + ctx := context.Background() + err = runner.Stop(ctx, "nonexistent") + if err == nil { + t.Error("Stop() should fail for nonexistent process") + } + + err = runner.SetDirectOutput("nonexistent", nil) + if err == nil { + t.Error("SetDirectOutput() should fail for nonexistent process") + } + + _, err = runner.GetPtyWriter("nonexistent") + if err == nil { + t.Error("GetPtyWriter() should fail for nonexistent process") + } +} + +func TestInteractiveRunner_IsProcessRunning(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Non-existent process + if runner.IsProcessRunning("nonexistent") { + t.Error("IsProcessRunning() should return false for nonexistent process") + } + + // Start a process + req := InteractiveStartRequest{ + SessionID: "running-test", + Command: []string{"sleep", "10"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Process should be running + if !runner.IsProcessRunning(info.ID) { + t.Error("IsProcessRunning() should return true for running process") + } + + // Stop the process + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) + + // Give it time to clean up + time.Sleep(200 * time.Millisecond) + + // Process should no longer be running + if runner.IsProcessRunning(info.ID) { + t.Error("IsProcessRunning() should return false after stop") + } +} + +func TestInteractiveRunner_IsProcessReadyOrPending(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Non-existent process + if runner.IsProcessReadyOrPending("nonexistent") { + t.Error("IsProcessReadyOrPending() should return false for nonexistent process") + } + + // Start a deferred process (not started yet) + req := InteractiveStartRequest{ + SessionID: "pending-test", + Command: []string{"cat"}, + // ImmediateStart: false (deferred) + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Deferred process should be "ready or pending" + if !runner.IsProcessReadyOrPending(info.ID) { + t.Error("IsProcessReadyOrPending() should return true for pending process") + } + + // But not "running" yet + if runner.IsProcessRunning(info.ID) { + t.Error("IsProcessRunning() should return false for pending process") + } + + // Trigger start via resize + err = runner.ResizeBySession("pending-test", 80, 24) + if err != nil { + t.Fatalf("ResizeBySession() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Now it should be both "running" and "ready or pending" + if !runner.IsProcessRunning(info.ID) { + t.Error("IsProcessRunning() should return true after start") + } + if !runner.IsProcessReadyOrPending(info.ID) { + t.Error("IsProcessReadyOrPending() should return true for running process") + } + + // Stop the process + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) +} diff --git a/apps/backend/internal/agentctl/server/process/runner_terminal_test.go b/apps/backend/internal/agentctl/server/process/runner_terminal_test.go new file mode 100644 index 0000000000..cd90e46845 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/runner_terminal_test.go @@ -0,0 +1,102 @@ +package process + +import ( + "context" + "testing" + "time" +) + +func TestContainsDSRQuery(t *testing.T) { + tests := []struct { + name string + data []byte + want bool + }{ + {"ESC[6n", []byte("\x1b[6n"), true}, + {"ESC[?6n", []byte("\x1b[?6n"), true}, + {"mixed content with DSR", []byte("text\x1b[6nmore"), true}, + {"no escape", []byte("hello world"), false}, + {"partial ESC[6", []byte("\x1b[6"), false}, + {"ESC[c is not DSR", []byte("\x1b[c"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := containsDSRQuery(tt.data); got != tt.want { + t.Errorf("containsDSRQuery(%q) = %v, want %v", tt.data, got, tt.want) + } + }) + } +} + +func TestContainsDA1Query(t *testing.T) { + tests := []struct { + name string + data []byte + want bool + }{ + {"ESC[c (DA1 no param)", []byte("\x1b[c"), true}, + {"ESC[0c (DA1 explicit 0)", []byte("\x1b[0c"), true}, + {"mixed with DA1", []byte("text\x1b[cmore"), true}, + {"both DSR and DA1", []byte("\x1b[6n\x1b[c"), true}, + {"no escape", []byte("hello world"), false}, + {"ESC[1c is cursor forward, not DA1", []byte("\x1b[1c"), false}, + {"ESC[2c is cursor forward, not DA1", []byte("\x1b[2c"), false}, + {"ESC[5c is cursor forward, not DA1", []byte("\x1b[5c"), false}, + {"ESC[9c is cursor forward, not DA1", []byte("\x1b[9c"), false}, + {"partial ESC[", []byte("\x1b["), false}, + {"empty data", []byte{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := containsDA1Query(tt.data); got != tt.want { + t.Errorf("containsDA1Query(%q) = %v, want %v", tt.data, got, tt.want) + } + }) + } +} + +func TestInteractiveRunner_ResizeByProcessID(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start a deferred process + req := InteractiveStartRequest{ + SessionID: "resize-pid-test", + Command: []string{"cat"}, + } + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Process should not be running yet + if runner.IsProcessRunning(info.ID) { + t.Error("process should not be running before resize") + } + + // Trigger start via ResizeByProcessID + if err := runner.ResizeByProcessID(info.ID, 80, 24); err != nil { + t.Fatalf("ResizeByProcessID() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + if !runner.IsProcessRunning(info.ID) { + t.Error("process should be running after ResizeByProcessID") + } + + // Subsequent resize should succeed + if err := runner.ResizeByProcessID(info.ID, 120, 40); err != nil { + t.Errorf("second ResizeByProcessID() error = %v", err) + } + + // Non-existent process should fail + if err := runner.ResizeByProcessID("nonexistent", 80, 24); err == nil { + t.Error("ResizeByProcessID() should fail for nonexistent process") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go new file mode 100644 index 0000000000..551be2c3ab --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -0,0 +1,400 @@ +package process + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// updateFiles updates the file listing +func (wt *WorkspaceTracker) updateFiles(ctx context.Context) { + files, err := wt.getFileList(ctx) + if err != nil { + wt.logger.Debug("failed to get file list", zap.Error(err)) + return + } + + wt.mu.Lock() + wt.currentFiles = files + wt.mu.Unlock() +} + +// getFileList retrieves the list of files in the workspace +func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpdate, error) { + update := types.FileListUpdate{ + Timestamp: time.Now(), + Files: []types.FileEntry{}, + } + + // Use git ls-files to get tracked files + cmd := exec.CommandContext(ctx, "git", "ls-files") + cmd.Dir = wt.workDir + out, err := cmd.Output() + if err != nil { + return update, err + } + + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + update.Files = append(update.Files, types.FileEntry{ + Path: line, + IsDir: false, + }) + } + + return update, nil +} + +// GetFileTree returns the file tree for a given path and depth +func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*types.FileTreeNode, error) { + // Resolve the full path with path traversal protection + fullPath := filepath.Join(wt.workDir, filepath.Clean(reqPath)) + cleanWorkDir := filepath.Clean(wt.workDir) + if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir { + return nil, fmt.Errorf("path traversal detected") + } + + // Check if path exists + info, err := os.Stat(fullPath) + if err != nil { + return nil, fmt.Errorf("path not found: %w", err) + } + + // Build the tree + node, err := wt.buildFileTreeNode(fullPath, reqPath, info, depth, 0) + if err != nil { + return nil, err + } + + return node, nil +} + +// buildFileTreeNode recursively builds a file tree node +func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.FileInfo, maxDepth, currentDepth int) (*types.FileTreeNode, error) { + node := &types.FileTreeNode{ + Name: info.Name(), + Path: relPath, + IsDir: info.IsDir(), + Size: info.Size(), + } + + // If it's a file or we've reached max depth, return + if !info.IsDir() || (maxDepth > 0 && currentDepth >= maxDepth) { + return node, nil + } + + // Read directory contents + entries, err := os.ReadDir(fullPath) + if err != nil { + return node, nil // Return node without children on error + } + + // Build children + node.Children = make([]*types.FileTreeNode, 0, len(entries)) + for _, entry := range entries { + // Skip specific directories that should be ignored + name := entry.Name() + if name == ".git" || name == "node_modules" || name == ".next" || name == "dist" || name == "build" { + continue + } + + childFullPath := filepath.Join(fullPath, name) + childRelPath := filepath.Join(relPath, name) + + childInfo, err := entry.Info() + if err != nil { + continue + } + + childNode, err := wt.buildFileTreeNode(childFullPath, childRelPath, childInfo, maxDepth, currentDepth+1) + if err != nil { + continue + } + + node.Children = append(node.Children, childNode) + } + + return node, nil +} + +// resolveSafePath resolves reqPath to an absolute path within workDir, +// rejecting any path traversal attempts. +func (wt *WorkspaceTracker) resolveSafePath(reqPath string) (string, error) { + cleanWorkDir := filepath.Clean(wt.workDir) + cleanReqPath := filepath.Clean(reqPath) + + var fullPath string + if filepath.IsAbs(cleanReqPath) && strings.HasPrefix(cleanReqPath, cleanWorkDir+string(os.PathSeparator)) { + fullPath = cleanReqPath + } else { + fullPath = filepath.Join(wt.workDir, cleanReqPath) + } + + if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir { + return "", fmt.Errorf("path traversal detected") + } + + return fullPath, nil +} + +// GetFileContent returns the content of a file. +// If the file is not valid UTF-8, it is base64-encoded and isBinary is true. +func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, error) { + fullPath, err := wt.resolveSafePath(reqPath) + if err != nil { + return "", 0, false, err + } + + // Check if file exists and is a regular file + info, err := os.Stat(fullPath) + if err != nil { + return "", 0, false, fmt.Errorf("file not found: %w", err) + } + + if info.IsDir() { + return "", 0, false, fmt.Errorf("path is a directory, not a file") + } + + // Check file size (limit to 10MB) + const maxFileSize = 10 * 1024 * 1024 + if info.Size() > maxFileSize { + return "", info.Size(), false, fmt.Errorf("file too large (max 10MB)") + } + + // Read file content + file, err := os.Open(fullPath) + if err != nil { + return "", 0, false, fmt.Errorf("failed to open file: %w", err) + } + defer func() { + _ = file.Close() + }() + + content, err := io.ReadAll(file) + if err != nil { + return "", 0, false, fmt.Errorf("failed to read file: %w", err) + } + + // Detect binary: if content is not valid UTF-8, base64-encode it + if !utf8.Valid(content) { + encoded := base64.StdEncoding.EncodeToString(content) + return encoded, info.Size(), true, nil + } + + return string(content), info.Size(), false, nil +} + +// ApplyFileDiff applies a unified diff to a file with conflict detection +// Uses git apply for reliable, battle-tested patch application +func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, originalHash string) (string, error) { + fullPath, err := wt.resolveSafePath(reqPath) + if err != nil { + return "", err + } + + cleanWorkDir := filepath.Clean(wt.workDir) + + // Read current file content + currentContent, _, _, err := wt.GetFileContent(reqPath) + if err != nil { + return "", fmt.Errorf("failed to read current file: %w", err) + } + + // Calculate hash of current content for conflict detection + currentHash := calculateSHA256(currentContent) + if originalHash != "" && currentHash != originalHash { + return "", fmt.Errorf("conflict detected: file has been modified (expected hash %s, got %s)", originalHash, currentHash) + } + + // Write diff to a temporary patch file + patchFile := filepath.Join(wt.workDir, ".kandev-patch.tmp") + err = os.WriteFile(patchFile, []byte(unifiedDiff), 0644) + if err != nil { + return "", fmt.Errorf("failed to write patch file: %w", err) + } + defer func() { + _ = os.Remove(patchFile) // Best effort cleanup + }() + + // Use git apply to apply the patch directly to the file + // This is much more reliable than custom diff application + cmd := exec.Command("git", "apply", "-p0", "--unidiff-zero", "--whitespace=nowarn", patchFile) + cmd.Dir = wt.workDir + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git apply failed: %w\nOutput: %s", err, string(output)) + } + + // Read the updated content + updatedContent, err := os.ReadFile(fullPath) + if err != nil { + return "", fmt.Errorf("failed to read updated file: %w", err) + } + + // Calculate new hash + newHash := calculateSHA256(string(updatedContent)) + + // Notify with the relative path + relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) + wt.addPendingChange(relPath, types.FileOpWrite) + + wt.logger.Debug("applied file diff using git apply", + zap.String("path", relPath), + zap.String("old_hash", currentHash), + zap.String("new_hash", newHash), + ) + + return newHash, nil +} + +// CreateFile creates a new file in the workspace +func (wt *WorkspaceTracker) CreateFile(reqPath string) error { + fullPath, err := wt.resolveSafePath(reqPath) + if err != nil { + return err + } + + // Create intermediate directories + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directories: %w", err) + } + + // Atomically create the file, failing if it already exists + f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("file already exists: %s", reqPath) + } + return fmt.Errorf("failed to create file: %w", err) + } + _ = f.Close() + + // Notify with the relative path + cleanWorkDir := filepath.Clean(wt.workDir) + relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) + wt.addPendingChange(relPath, types.FileOpCreate) + + return nil +} + +// DeleteFile deletes a file from the workspace +func (wt *WorkspaceTracker) DeleteFile(reqPath string) error { + fullPath, err := wt.resolveSafePath(reqPath) + if err != nil { + return err + } + + // Check if file exists + info, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("file does not exist: %s", reqPath) + } + return fmt.Errorf("failed to stat file: %w", err) + } + + // Don't allow deleting directories + if info.IsDir() { + return fmt.Errorf("cannot delete directory: %s", reqPath) + } + + // Delete the file + if err := os.Remove(fullPath); err != nil { + return fmt.Errorf("failed to delete file: %w", err) + } + + // Notify with the relative path + cleanWorkDir := filepath.Clean(wt.workDir) + relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) + wt.addPendingChange(relPath, types.FileOpRemove) + + return nil +} + +// calculateSHA256 calculates the SHA256 hash of a string +func calculateSHA256(content string) string { + hash := sha256.Sum256([]byte(content)) + return hex.EncodeToString(hash[:]) +} + +// scoredMatch holds a file path and its match score for sorting +type scoredMatch struct { + path string + score int +} + +// SearchFiles searches for files matching the query string. +// It uses fuzzy matching with scoring based on how well the query matches. +func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { + if query == "" { + return []string{} + } + if limit <= 0 { + limit = 20 + } + + query = strings.ToLower(query) + var matches []scoredMatch + + wt.mu.RLock() + files := wt.currentFiles.Files + wt.mu.RUnlock() + + for _, file := range files { + path := file.Path + lowerPath := strings.ToLower(path) + name := filepath.Base(lowerPath) + + score := 0 + switch { + case name == query: + score = 100 // Exact filename match + case strings.HasPrefix(name, query): + score = 75 // Filename starts with query + case strings.Contains(name, query): + score = 50 // Filename contains query + case strings.Contains(lowerPath, query): + score = 25 // Path contains query + } + + if score > 0 { + matches = append(matches, scoredMatch{path: path, score: score}) + } + } + + // Sort by score descending + sort.Slice(matches, func(i, j int) bool { + if matches[i].score != matches[j].score { + return matches[i].score > matches[j].score + } + // Secondary sort by path length (shorter paths first) + return len(matches[i].path) < len(matches[j].path) + }) + + // Return top limit results + result := make([]string, 0, limit) + for i := 0; i < len(matches) && i < limit; i++ { + result = append(result, matches[i].path) + } + + return result +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_commits.go b/apps/backend/internal/agentctl/server/process/workspace_git_commits.go new file mode 100644 index 0000000000..6a324aa11a --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_git_commits.go @@ -0,0 +1,112 @@ +package process + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// getCommitsSince returns commits from baseCommit (exclusive) to HEAD (inclusive) +func (wt *WorkspaceTracker) getCommitsSince(ctx context.Context, baseCommit string) []*types.GitCommitNotification { + // Get list of commits with metadata + // Format: SHA|ParentSHA|AuthorName|AuthorEmail|Subject|AuthorDateISO + cmd := exec.CommandContext(ctx, "git", "log", + "--format=%H|%P|%an|%ae|%s|%aI", + baseCommit+"..HEAD") + cmd.Dir = wt.workDir + out, err := cmd.Output() + if err != nil { + wt.logger.Debug("failed to get commits since base", + zap.String("base", baseCommit), + zap.Error(err)) + return nil + } + + output := strings.TrimSpace(string(out)) + if output == "" { + return nil + } + + lines := strings.Split(output, "\n") + commits := make([]*types.GitCommitNotification, 0, len(lines)) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.SplitN(line, "|", 6) + if len(parts) < 6 { + continue + } + + sha := parts[0] + parentSHA := parts[1] + // Handle multiple parents (merge commits) - just take the first + if idx := strings.Index(parentSHA, " "); idx > 0 { + parentSHA = parentSHA[:idx] + } + + committedAt, err := time.Parse(time.RFC3339, parts[5]) + if err != nil { + committedAt = time.Now().UTC() + } + + // Get stats for this commit + filesChanged, insertions, deletions := wt.getCommitStats(ctx, sha) + + commits = append(commits, &types.GitCommitNotification{ + Timestamp: time.Now(), + CommitSHA: sha, + ParentSHA: parentSHA, + AuthorName: parts[2], + AuthorEmail: parts[3], + Message: parts[4], + FilesChanged: filesChanged, + Insertions: insertions, + Deletions: deletions, + CommittedAt: committedAt, + }) + } + + return commits +} + +// getCommitStats returns the number of files changed, insertions, and deletions for a commit +func (wt *WorkspaceTracker) getCommitStats(ctx context.Context, sha string) (filesChanged, insertions, deletions int) { + cmd := exec.CommandContext(ctx, "git", "show", "--stat", "--format=", sha) + cmd.Dir = wt.workDir + out, err := cmd.Output() + if err != nil { + return 0, 0, 0 + } + + // Parse the last line which contains summary like "3 files changed, 10 insertions(+), 5 deletions(-)" + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 { + return 0, 0, 0 + } + + summary := lines[len(lines)-1] + // Simple parsing - look for numbers before keywords + parts := strings.Fields(summary) + for i, part := range parts { + if strings.Contains(part, "file") && i > 0 { + _, _ = fmt.Sscanf(parts[i-1], "%d", &filesChanged) + } + if strings.Contains(part, "insertion") && i > 0 { + _, _ = fmt.Sscanf(parts[i-1], "%d", &insertions) + } + if strings.Contains(part, "deletion") && i > 0 { + _, _ = fmt.Sscanf(parts[i-1], "%d", &deletions) + } + } + + return filesChanged, insertions, deletions +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go new file mode 100644 index 0000000000..dbbb1710bc --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go @@ -0,0 +1,154 @@ +package process + +import ( + "context" + "os/exec" + "strconv" + "strings" + + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// enrichWithDiffData adds diff information (additions, deletions, diff content) to file info +func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *types.GitStatusUpdate) { + // Use remote branch if available, otherwise use HEAD + baseRef := "HEAD" + if update.RemoteBranch != "" { + baseRef = update.RemoteBranch + } + + wt.enrichWithUnstagedDiff(ctx, update, baseRef) + wt.enrichWithStagedDiff(ctx, update, baseRef) + wt.enrichUntrackedFileDiffs(ctx, update) +} + +// enrichWithUnstagedDiff populates additions/deletions and diff content for files +// with unstaged changes by comparing the worktree against baseRef. +func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update *types.GitStatusUpdate, baseRef string) { + numstatCmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef) + numstatCmd.Dir = wt.workDir + numstatOut, err := numstatCmd.Output() + if err != nil { + wt.logger.Debug("failed to get numstat", zap.Error(err)) + return + } + + lines := strings.Split(string(numstatOut), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 3 { + continue + } + additions, _ := strconv.Atoi(parts[0]) + deletions, _ := strconv.Atoi(parts[1]) + filePath := parts[2] + + // Only update file info if it exists in status (uncommitted changes). + // Files that appear in diff but not in status are committed changes - we don't + // add them to the Files map as that would make git status show already-committed files. + fileInfo, exists := update.Files[filePath] + if !exists { + continue + } + fileInfo.Additions = additions + fileInfo.Deletions = deletions + + // Get the actual diff content for this file (compare against base branch) + diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath) + diffCmd.Dir = wt.workDir + if diffOut, err := diffCmd.Output(); err == nil { + fileInfo.Diff = string(diffOut) + } + + update.Files[filePath] = fileInfo + } +} + +// enrichWithStagedDiff populates additions/deletions and diff content for staged files +// that have no additional unstaged changes, using git diff --cached. +func (wt *WorkspaceTracker) enrichWithStagedDiff(ctx context.Context, update *types.GitStatusUpdate, baseRef string) { + // For staged files that don't have unstaged changes, we need to get the diff from the index. + // The first diff (git diff baseRef) shows worktree vs baseRef, but if a file is staged + // and has no additional unstaged changes, its diff won't appear there. + stagedCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--numstat", baseRef) + stagedCmd.Dir = wt.workDir + stagedOut, err := stagedCmd.Output() + if err != nil { + return + } + + lines := strings.Split(string(stagedOut), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 3 { + continue + } + additions, _ := strconv.Atoi(parts[0]) + deletions, _ := strconv.Atoi(parts[1]) + filePath := parts[2] + + fileInfo, exists := update.Files[filePath] + if !exists { + continue + } + // Only set additions/deletions if they weren't already set by the unstaged diff. + // This prevents double-counting when changes appear in both diffs. + if fileInfo.Additions == 0 && fileInfo.Deletions == 0 { + fileInfo.Additions = additions + fileInfo.Deletions = deletions + } + // Get the staged diff content if we don't have diff content yet + if fileInfo.Diff == "" { + diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", baseRef, "--", filePath) + diffCmd.Dir = wt.workDir + if diffOut, err := diffCmd.Output(); err == nil { + fileInfo.Diff = string(diffOut) + } + } + update.Files[filePath] = fileInfo + } +} + +// enrichUntrackedFileDiffs builds a synthetic git diff for untracked files showing all +// lines as additions, so the diff viewer can display their full content. +func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update *types.GitStatusUpdate) { + for filePath, fileInfo := range update.Files { + if fileInfo.Status != "untracked" { + continue + } + catCmd := exec.CommandContext(ctx, "cat", filePath) + catCmd.Dir = wt.workDir + catOut, err := catCmd.Output() + if err != nil { + continue + } + content := string(catOut) + lines := strings.Split(content, "\n") + fileInfo.Additions = len(lines) + fileInfo.Deletions = 0 + + // Format as a proper git diff with all required headers. + // The @git-diff-view/react library requires the full git diff format. + var diffBuilder strings.Builder + diffBuilder.WriteString("diff --git a/" + filePath + " b/" + filePath + "\n") + diffBuilder.WriteString("new file mode 100644\n") + diffBuilder.WriteString("index 0000000..0000000\n") + diffBuilder.WriteString("--- /dev/null\n") + diffBuilder.WriteString("+++ b/" + filePath + "\n") + diffBuilder.WriteString("@@ -0,0 +1," + strconv.Itoa(len(lines)) + " @@\n") + for _, line := range lines { + diffBuilder.WriteString("+" + line + "\n") + } + fileInfo.Diff = diffBuilder.String() + update.Files[filePath] = fileInfo + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go new file mode 100644 index 0000000000..0448503301 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go @@ -0,0 +1,218 @@ +package process + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os/exec" + "strings" + "time" + + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// pollGitChanges periodically checks for git changes (commits, branch switches, staging) +// This catches manual git operations done via shell that file watching might miss +func (wt *WorkspaceTracker) pollGitChanges(ctx context.Context) { + defer wt.wg.Done() + + ticker := time.NewTicker(wt.gitPollInterval) + defer ticker.Stop() + + // Initialize cached HEAD SHA and index hash + wt.gitStateMu.Lock() + wt.cachedHeadSHA = wt.getHeadSHA(ctx) + wt.cachedIndexHash = wt.getGitStatusHash(ctx) + wt.gitStateMu.Unlock() + + wt.logger.Info("git polling started", + zap.Duration("interval", wt.gitPollInterval), + zap.String("initial_head", wt.cachedHeadSHA)) + + for { + select { + case <-ctx.Done(): + return + case <-wt.stopCh: + return + case <-ticker.C: + wt.checkGitChanges(ctx) + } + } +} + +// getHeadSHA returns the current HEAD commit SHA +func (wt *WorkspaceTracker) getHeadSHA(ctx context.Context) string { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") + cmd.Dir = wt.workDir + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// getGitStatusHash returns a hash of the git status porcelain output. +// This is used to detect changes to the git index (staging/unstaging) that don't +// change HEAD. The hash includes both the status codes and file paths. +func (wt *WorkspaceTracker) getGitStatusHash(ctx context.Context) string { + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--untracked-files=all") + cmd.Dir = wt.workDir + out, err := cmd.Output() + if err != nil { + return "" + } + hash := sha256.Sum256(out) + return hex.EncodeToString(hash[:]) +} + +// checkGitChanges checks if HEAD or git index has changed and processes changes +func (wt *WorkspaceTracker) checkGitChanges(ctx context.Context) { + currentHead := wt.getHeadSHA(ctx) + currentIndexHash := wt.getGitStatusHash(ctx) + + wt.gitStateMu.RLock() + previousHead := wt.cachedHeadSHA + previousIndexHash := wt.cachedIndexHash + wt.gitStateMu.RUnlock() + + headChanged := currentHead != "" && currentHead != previousHead + indexChanged := currentIndexHash != "" && currentIndexHash != previousIndexHash + + // If neither HEAD nor index changed, nothing to do + if !headChanged && !indexChanged { + return + } + + // Update cached index hash (HEAD will be updated below if it changed) + if indexChanged && !headChanged { + wt.gitStateMu.Lock() + wt.cachedIndexHash = currentIndexHash + wt.gitStateMu.Unlock() + + wt.logger.Debug("git index changed (staging/unstaging detected)") + wt.updateGitStatus(ctx) + return + } + + // HEAD changed - handle normally + if !headChanged { + return + } + + wt.logger.Info("git HEAD changed, syncing", + zap.String("previous", previousHead), + zap.String("current", currentHead)) + + // Update cached HEAD and index hash + wt.gitStateMu.Lock() + wt.cachedHeadSHA = currentHead + wt.cachedIndexHash = currentIndexHash + wt.gitStateMu.Unlock() + + // Check if history was rewritten (reset, rebase, amend, etc.) + // There are three cases: + // 1. HEAD moved backward: currentHead is an ancestor of previousHead (e.g., git reset HEAD~1) + // 2. History rewritten: previousHead is NOT reachable from currentHead (e.g., git rebase -i, git commit --amend) + // 3. HEAD moved forward: previousHead IS an ancestor of currentHead (normal commits) + if previousHead != "" { + switch { + case wt.isAncestor(ctx, currentHead, previousHead): + // Case 1: HEAD moved backward - emit reset notification + wt.logger.Info("detected git reset (HEAD moved backward)", + zap.String("previous", previousHead), + zap.String("current", currentHead)) + wt.notifyWorkspaceStreamGitReset(&types.GitResetNotification{ + Timestamp: time.Now(), + PreviousHead: previousHead, + CurrentHead: currentHead, + }) + case !wt.isAncestor(ctx, previousHead, currentHead): + // Case 2: History was rewritten - previousHead is not reachable from currentHead + // This happens with interactive rebase, commit amend, etc. + wt.logger.Info("detected git history rewrite (previous HEAD not reachable)", + zap.String("previous", previousHead), + zap.String("current", currentHead)) + wt.notifyWorkspaceStreamGitReset(&types.GitResetNotification{ + Timestamp: time.Now(), + PreviousHead: previousHead, + CurrentHead: currentHead, + }) + default: + // Case 3: HEAD moved forward normally - get new commits + commits := wt.getCommitsSince(ctx, previousHead) + + // Filter out commits that are already on remote branches. + // This prevents recording upstream commits as session commits when + // the user pulls/rebases onto a remote branch (e.g., git reset --hard main). + localCommits := wt.filterLocalCommits(ctx, commits) + + for _, commit := range localCommits { + wt.notifyWorkspaceStreamGitCommit(commit) + } + if len(localCommits) > 0 { + wt.logger.Info("detected new commits via polling", + zap.Int("count", len(localCommits))) + } + } + } + + // Update and broadcast git status + wt.updateGitStatus(ctx) +} + +// isAncestor checks if commit1 is an ancestor of commit2 +func (wt *WorkspaceTracker) isAncestor(ctx context.Context, commit1, commit2 string) bool { + cmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", commit1, commit2) + cmd.Dir = wt.workDir + err := cmd.Run() + // Exit code 0 means commit1 IS an ancestor of commit2 + // Exit code 1 means commit1 is NOT an ancestor of commit2 + return err == nil +} + +// isOnRemote checks if a commit is reachable from any remote tracking branch. +// This is used to filter out upstream commits that came from a pull/fetch, +// as opposed to commits made locally in the session. +func (wt *WorkspaceTracker) isOnRemote(ctx context.Context, commitSHA string) bool { + // Use git branch -r --contains to check if commit is on any remote branch + cmd := exec.CommandContext(ctx, "git", "branch", "-r", "--contains", commitSHA) + cmd.Dir = wt.workDir + out, err := cmd.Output() + if err != nil { + // If the command fails, assume it's not on remote (safer default) + return false + } + // If output is non-empty, commit is reachable from at least one remote branch + return strings.TrimSpace(string(out)) != "" +} + +// filterLocalCommits filters out commits that are already on remote branches. +// This ensures we only report commits made locally in the session, not upstream commits +// that came from pulling/rebasing onto a remote branch. +func (wt *WorkspaceTracker) filterLocalCommits(ctx context.Context, commits []*types.GitCommitNotification) []*types.GitCommitNotification { + if len(commits) == 0 { + return commits + } + + localCommits := make([]*types.GitCommitNotification, 0, len(commits)) + for _, commit := range commits { + if !wt.isOnRemote(ctx, commit.CommitSHA) { + localCommits = append(localCommits, commit) + } else { + wt.logger.Debug("skipping upstream commit (already on remote)", + zap.String("sha", commit.CommitSHA), + zap.String("message", commit.Message)) + } + } + + if skipped := len(commits) - len(localCommits); skipped > 0 { + wt.logger.Info("filtered upstream commits", + zap.Int("total", len(commits)), + zap.Int("skipped", skipped), + zap.Int("local", len(localCommits))) + } + + return localCommits +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_status.go b/apps/backend/internal/agentctl/server/process/workspace_git_status.go new file mode 100644 index 0000000000..f9bbb60ac9 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_git_status.go @@ -0,0 +1,219 @@ +package process + +import ( + "context" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// updateGitStatus updates the git status +func (wt *WorkspaceTracker) updateGitStatus(ctx context.Context) { + status, err := wt.getGitStatus(ctx) + if err != nil { + wt.logger.Warn("updateGitStatus: getGitStatus failed", zap.Error(err)) + return + } + + wt.mu.Lock() + wt.currentStatus = status + wt.mu.Unlock() + + // Notify workspace stream subscribers + wt.notifyWorkspaceStreamGitStatus(status) +} + +// RefreshGitStatus forces a git status refresh and notifies subscribers. +// Useful after index-only changes (stage/unstage) that the file watcher won't detect. +func (wt *WorkspaceTracker) RefreshGitStatus(ctx context.Context) { + wt.updateGitStatus(ctx) +} + +// getGitStatus retrieves the current git status +func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUpdate, error) { + update := types.GitStatusUpdate{ + Timestamp: time.Now(), + Modified: []string{}, + Added: []string{}, + Deleted: []string{}, + Untracked: []string{}, + Renamed: []string{}, + Files: make(map[string]types.FileInfo), + } + + if err := wt.getGitBranchInfo(ctx, &update); err != nil { + return update, err + } + + wt.getAheadBehindCounts(ctx, &update) + + if err := wt.parseGitStatusOutput(ctx, &update); err != nil { + return update, err + } + + // Enrich file info with diff data (additions, deletions, and actual diff content) + wt.enrichWithDiffData(ctx, &update) + + return update, nil +} + +// getGitBranchInfo populates branch, remote branch, head commit, and base commit fields. +func (wt *WorkspaceTracker) getGitBranchInfo(ctx context.Context, update *types.GitStatusUpdate) error { + // Get current branch + branchCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") + branchCmd.Dir = wt.workDir + branchOut, err := branchCmd.Output() + if err != nil { + return err + } + update.Branch = strings.TrimSpace(string(branchOut)) + + // Get remote branch + remoteCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "@{upstream}") + remoteCmd.Dir = wt.workDir + if remoteOut, err := remoteCmd.Output(); err == nil { + update.RemoteBranch = strings.TrimSpace(string(remoteOut)) + } + + // Get current HEAD commit SHA + headCmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") + headCmd.Dir = wt.workDir + if headOut, err := headCmd.Output(); err == nil { + update.HeadCommit = strings.TrimSpace(string(headOut)) + } + + // Get base commit SHA (base branch HEAD) + // Use remote branch if available, fall back to main/master + baseBranch := update.RemoteBranch + if baseBranch == "" { + // Try main first, then master + baseBranch = "main" + checkCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "main") + checkCmd.Dir = wt.workDir + if err := checkCmd.Run(); err != nil { + baseBranch = "master" + } + } + baseCmd := exec.CommandContext(ctx, "git", "rev-parse", baseBranch) + baseCmd.Dir = wt.workDir + if baseOut, err := baseCmd.Output(); err == nil { + update.BaseCommit = strings.TrimSpace(string(baseOut)) + } + + return nil +} + +// getAheadBehindCounts populates the Ahead/Behind fields relative to the tracking or +// origin branch. Falls back to origin/main then origin/master when no upstream is set. +func (wt *WorkspaceTracker) getAheadBehindCounts(ctx context.Context, update *types.GitStatusUpdate) { + // Use remote branch if available, otherwise fall back to origin/main or origin/master + // This handles worktree branches that don't have an upstream tracking branch set + compareRef := update.RemoteBranch + if compareRef == "" { + // Try origin/main first, then origin/master + checkCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "origin/main") + checkCmd.Dir = wt.workDir + if err := checkCmd.Run(); err == nil { + compareRef = "origin/main" + } else { + checkCmd2 := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "origin/master") + checkCmd2.Dir = wt.workDir + if err := checkCmd2.Run(); err == nil { + compareRef = "origin/master" + } + } + } + if compareRef == "" { + return + } + countCmd := exec.CommandContext(ctx, "git", "rev-list", "--left-right", "--count", update.Branch+"..."+compareRef) + countCmd.Dir = wt.workDir + if countOut, err := countCmd.Output(); err == nil { + parts := strings.Fields(string(countOut)) + if len(parts) == 2 { + update.Ahead, _ = strconv.Atoi(parts[0]) + update.Behind, _ = strconv.Atoi(parts[1]) + } + } +} + +// parseGitStatusOutput runs git status --porcelain and populates the file lists and map. +func (wt *WorkspaceTracker) parseGitStatusOutput(ctx context.Context, update *types.GitStatusUpdate) error { + // --untracked-files=all shows all files in untracked directories, not just the directory name + statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--untracked-files=all") + statusCmd.Dir = wt.workDir + statusOut, err := statusCmd.Output() + if err != nil { + return err + } + + // Git status --porcelain format: XY filename + // X = index (staged) status, Y = working tree (unstaged) status + // ' ' = unmodified, M = modified, A = added, D = deleted, R = renamed, ? = untracked + lines := strings.Split(string(statusOut), "\n") + for _, line := range lines { + if len(line) < 3 { + continue + } + wt.applyPorcelainLine(line, update) + } + return nil +} + +// applyPorcelainLine parses a single git status --porcelain line and updates the status update. +func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitStatusUpdate) { + indexStatus := line[0] // Staged status (X) + workTreeStatus := line[1] // Unstaged status (Y) + filePath := strings.TrimSpace(line[3:]) + + fileInfo := types.FileInfo{Path: filePath} + + // Determine staged status based on index and worktree status. + // Prioritize worktree changes as they represent the current state. + switch { + case indexStatus == '?' && workTreeStatus == '?': + fileInfo.Status = "untracked" + fileInfo.Staged = false + update.Untracked = append(update.Untracked, filePath) + case workTreeStatus == 'D': + // File deleted in worktree - this is an unstaged deletion + fileInfo.Status = fileStatusDeleted + fileInfo.Staged = false + update.Deleted = append(update.Deleted, filePath) + case indexStatus == 'D': + // File deleted and staged + fileInfo.Status = fileStatusDeleted + fileInfo.Staged = true + update.Deleted = append(update.Deleted, filePath) + case workTreeStatus == 'M': + // Modified in worktree - unstaged modification + fileInfo.Status = fileStatusModified + fileInfo.Staged = false + update.Modified = append(update.Modified, filePath) + case indexStatus == 'M': + // Modified and staged (no worktree changes) + fileInfo.Status = fileStatusModified + fileInfo.Staged = true + update.Modified = append(update.Modified, filePath) + case indexStatus == 'A': + // Added and staged + fileInfo.Status = "added" + fileInfo.Staged = true + update.Added = append(update.Added, filePath) + case indexStatus == 'R': + fileInfo.Status = "renamed" + fileInfo.Staged = true + // Renamed files have format "old -> new" + if idx := strings.Index(filePath, " -> "); idx != -1 { + fileInfo.OldPath = filePath[:idx] + fileInfo.Path = filePath[idx+4:] + } + update.Renamed = append(update.Renamed, filePath) + } + + update.Files[filePath] = fileInfo +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_monitor.go b/apps/backend/internal/agentctl/server/process/workspace_monitor.go new file mode 100644 index 0000000000..2fc0f7c2f0 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_monitor.go @@ -0,0 +1,200 @@ +package process + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// monitorLoop handles debounced filesystem change events +// When files change, we wait for activity to settle, then update everything at once +func (wt *WorkspaceTracker) monitorLoop(ctx context.Context) { + defer wt.wg.Done() + + // Debounce duration - wait this long after last file change before updating + const debounceDuration = 300 * time.Millisecond + + var debounceTimer *time.Timer + var pendingUpdate bool + + // Initial update + wt.updateGitStatus(ctx) + wt.updateFiles(ctx) + + for { + select { + case <-ctx.Done(): + if debounceTimer != nil { + debounceTimer.Stop() + } + return + case <-wt.stopCh: + if debounceTimer != nil { + debounceTimer.Stop() + } + return + case <-wt.fsChangeTrigger: + // File change detected - start or reset debounce timer + if debounceTimer == nil { + debounceTimer = time.NewTimer(debounceDuration) + } else { + // Reset the timer if already running + if !debounceTimer.Stop() { + select { + case <-debounceTimer.C: + default: + } + } + debounceTimer.Reset(debounceDuration) + } + pendingUpdate = true + case <-func() <-chan time.Time { + if debounceTimer != nil { + return debounceTimer.C + } + return nil + }(): + // Debounce timer fired - update everything + if pendingUpdate { + wt.updateGitStatus(ctx) + wt.updateFiles(ctx) + + // Drain pending changes and emit specific notifications + wt.pendingChangesMu.Lock() + changes := wt.pendingChanges + wt.pendingChanges = nil + wt.pendingChangesMu.Unlock() + + wt.emitFileChanges(changes) + pendingUpdate = false + } + debounceTimer = nil + } + } +} + +// watchFilesystem watches for filesystem changes and triggers debounced updates +func (wt *WorkspaceTracker) watchFilesystem(ctx context.Context) { + defer wt.wg.Done() + + for { + select { + case <-ctx.Done(): + return + case <-wt.stopCh: + return + case event, ok := <-wt.watcher.Events: + if !ok { + return + } + + // Ignore CHMOD events - permission changes don't affect file content + // and shouldn't trigger UI refreshes. This prevents loops from filesystem + // scanners, git operations, or other tools that touch file permissions. + if event.Op == fsnotify.Chmod { + continue + } + + // If a directory was created, watch it + if event.Op&fsnotify.Create == fsnotify.Create { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + if err := wt.addDirectoryRecursive(event.Name); err != nil { + wt.logger.Debug("failed to watch new directory", zap.Error(err)) + } + } + } + + // Record specific change with relative path + relPath, relErr := filepath.Rel(wt.workDir, event.Name) + if relErr != nil { + relPath = event.Name + } + wt.addPendingChange(relPath, fsOpToString(event.Op)) + + case err, ok := <-wt.watcher.Errors: + if !ok { + return + } + wt.logger.Debug("filesystem watcher error", zap.Error(err)) + } + } +} + +// fsOpToString converts an fsnotify operation to a FileOp string constant. +func fsOpToString(op fsnotify.Op) string { + switch { + case op&fsnotify.Create != 0: + return types.FileOpCreate + case op&fsnotify.Write != 0: + return types.FileOpWrite + case op&fsnotify.Remove != 0: + return types.FileOpRemove + case op&fsnotify.Rename != 0: + return types.FileOpRename + default: + return types.FileOpRefresh + } +} + +// addDirectoryRecursive adds a directory and all its subdirectories to the watcher +func (wt *WorkspaceTracker) addDirectoryRecursive(dir string) error { + return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip hidden directories and common ignore patterns + if d.IsDir() { + name := d.Name() + if name == ".git" || name == "node_modules" || name == ".next" || name == "dist" || name == "build" { + return filepath.SkipDir + } + + // Add directory to watcher + if err := wt.watcher.Add(path); err != nil { + wt.logger.Debug("failed to watch directory", zap.String("path", path), zap.Error(err)) + } + } + + return nil + }) +} + +// addPendingChange records a specific file change and triggers a debounced update. +func (wt *WorkspaceTracker) addPendingChange(relPath, operation string) { + wt.pendingChangesMu.Lock() + wt.pendingChanges = append(wt.pendingChanges, types.FileChangeNotification{ + Timestamp: time.Now(), + Path: relPath, + Operation: operation, + }) + wt.pendingChangesMu.Unlock() + + // Trigger debounced update (non-blocking) + select { + case wt.fsChangeTrigger <- struct{}{}: + default: + } +} + +// emitFileChanges sends accumulated file changes to subscribers. +// Falls back to a single generic refresh when there are too many changes or none. +func (wt *WorkspaceTracker) emitFileChanges(changes []types.FileChangeNotification) { + const maxSpecificChanges = 50 + if len(changes) == 0 || len(changes) > maxSpecificChanges { + wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ + Timestamp: time.Now(), + Operation: types.FileOpRefresh, + }) + return + } + for i := range changes { + wt.notifyWorkspaceStreamFileChange(changes[i]) + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_stream.go b/apps/backend/internal/agentctl/server/process/workspace_stream.go new file mode 100644 index 0000000000..5254178501 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_stream.go @@ -0,0 +1,162 @@ +package process + +import ( + "time" + + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// SubscribeWorkspaceStream creates a new unified workspace stream subscriber +// and sends current git status and file list immediately +func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber { + sub := make(types.WorkspaceStreamSubscriber, 100) + + 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() + currentStatus := wt.currentStatus + wt.mu.RUnlock() + + if currentStatus.Timestamp.IsZero() { + currentStatus.Timestamp = time.Now() + } + + // Send git status + select { + case sub <- types.NewWorkspaceGitStatus(¤tStatus): + default: + } + + return sub +} + +// UnsubscribeWorkspaceStream removes and closes a workspace stream subscriber +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)) +} + +// notifyWorkspaceStreamGitStatus sends git status to all workspace stream subscribers +func (wt *WorkspaceTracker) notifyWorkspaceStreamGitStatus(update types.GitStatusUpdate) { + wt.workspaceSubMu.RLock() + defer wt.workspaceSubMu.RUnlock() + + msg := types.NewWorkspaceGitStatus(&update) + for sub := range wt.workspaceStreamSubscribers { + select { + case sub <- msg: + default: + // Subscriber is slow, skip + } + } +} + +// notifyWorkspaceStreamGitCommit sends git commit notification to all workspace stream subscribers +func (wt *WorkspaceTracker) notifyWorkspaceStreamGitCommit(commit *types.GitCommitNotification) { + wt.workspaceSubMu.RLock() + defer wt.workspaceSubMu.RUnlock() + + msg := types.NewWorkspaceGitCommit(commit) + for sub := range wt.workspaceStreamSubscribers { + select { + case sub <- msg: + default: + // Subscriber is slow, skip + } + } +} + +// NotifyGitCommit notifies all subscribers about a new git commit. +// It also updates the cached HEAD SHA to prevent polling from re-detecting the same commit. +func (wt *WorkspaceTracker) NotifyGitCommit(commit *types.GitCommitNotification) { + // Update cached HEAD to the new commit SHA so polling doesn't re-detect it + if commit.CommitSHA != "" { + wt.gitStateMu.Lock() + wt.cachedHeadSHA = commit.CommitSHA + wt.gitStateMu.Unlock() + } + + wt.notifyWorkspaceStreamGitCommit(commit) +} + +// notifyWorkspaceStreamGitReset sends git reset notification to all workspace stream subscribers +func (wt *WorkspaceTracker) notifyWorkspaceStreamGitReset(reset *types.GitResetNotification) { + wt.workspaceSubMu.RLock() + defer wt.workspaceSubMu.RUnlock() + + msg := types.NewWorkspaceGitReset(reset) + for sub := range wt.workspaceStreamSubscribers { + select { + case sub <- msg: + default: + // Subscriber is slow, skip + } + } +} + +// notifyWorkspaceStreamFileChange sends file change notification to all workspace stream subscribers +func (wt *WorkspaceTracker) notifyWorkspaceStreamFileChange(notification types.FileChangeNotification) { + wt.workspaceSubMu.RLock() + defer wt.workspaceSubMu.RUnlock() + + msg := types.NewWorkspaceFileChange(¬ification) + for sub := range wt.workspaceStreamSubscribers { + select { + case sub <- msg: + default: + // Subscriber is slow, skip + } + } +} + +// 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 + } + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 87d13a79ff..3515add77d 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -2,21 +2,8 @@ package process import ( "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "io" - "io/fs" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" "sync" "time" - "unicode/utf8" "github.com/fsnotify/fsnotify" "github.com/kandev/kandev/internal/agentctl/types" @@ -27,6 +14,12 @@ import ( // DefaultGitPollInterval is the default interval for polling git status const DefaultGitPollInterval = 3 * time.Second +// fileStatus constants for FileInfo.Status values. +const ( + fileStatusDeleted = "deleted" + fileStatusModified = "modified" +) + // WorkspaceTracker monitors workspace changes and provides real-time updates type WorkspaceTracker struct { workDir string @@ -125,1366 +118,3 @@ func (wt *WorkspaceTracker) Stop() { wt.wg.Wait() wt.logger.Info("workspace tracker stopped") } - -// monitorLoop handles debounced filesystem change events -// When files change, we wait for activity to settle, then update everything at once -func (wt *WorkspaceTracker) monitorLoop(ctx context.Context) { - defer wt.wg.Done() - - // Debounce duration - wait this long after last file change before updating - const debounceDuration = 300 * time.Millisecond - - var debounceTimer *time.Timer - var pendingUpdate bool - - // Initial update - wt.updateGitStatus(ctx) - wt.updateFiles(ctx) - - for { - select { - case <-ctx.Done(): - if debounceTimer != nil { - debounceTimer.Stop() - } - return - case <-wt.stopCh: - if debounceTimer != nil { - debounceTimer.Stop() - } - return - case <-wt.fsChangeTrigger: - // File change detected - start or reset debounce timer - if debounceTimer == nil { - debounceTimer = time.NewTimer(debounceDuration) - } else { - // Reset the timer if already running - if !debounceTimer.Stop() { - select { - case <-debounceTimer.C: - default: - } - } - debounceTimer.Reset(debounceDuration) - } - pendingUpdate = true - case <-func() <-chan time.Time { - if debounceTimer != nil { - return debounceTimer.C - } - return nil - }(): - // Debounce timer fired - update everything - if pendingUpdate { - wt.updateGitStatus(ctx) - wt.updateFiles(ctx) - - // Drain pending changes and emit specific notifications - wt.pendingChangesMu.Lock() - changes := wt.pendingChanges - wt.pendingChanges = nil - wt.pendingChangesMu.Unlock() - - wt.emitFileChanges(changes) - pendingUpdate = false - } - debounceTimer = nil - } - } -} - -// pollGitChanges periodically checks for git changes (commits, branch switches, staging) -// This catches manual git operations done via shell that file watching might miss -func (wt *WorkspaceTracker) pollGitChanges(ctx context.Context) { - defer wt.wg.Done() - - ticker := time.NewTicker(wt.gitPollInterval) - defer ticker.Stop() - - // Initialize cached HEAD SHA and index hash - wt.gitStateMu.Lock() - wt.cachedHeadSHA = wt.getHeadSHA(ctx) - wt.cachedIndexHash = wt.getGitStatusHash(ctx) - wt.gitStateMu.Unlock() - - wt.logger.Info("git polling started", - zap.Duration("interval", wt.gitPollInterval), - zap.String("initial_head", wt.cachedHeadSHA)) - - for { - select { - case <-ctx.Done(): - return - case <-wt.stopCh: - return - case <-ticker.C: - wt.checkGitChanges(ctx) - } - } -} - -// getHeadSHA returns the current HEAD commit SHA -func (wt *WorkspaceTracker) getHeadSHA(ctx context.Context) string { - cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") - cmd.Dir = wt.workDir - out, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -// getGitStatusHash returns a hash of the git status porcelain output. -// This is used to detect changes to the git index (staging/unstaging) that don't -// change HEAD. The hash includes both the status codes and file paths. -func (wt *WorkspaceTracker) getGitStatusHash(ctx context.Context) string { - cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--untracked-files=all") - cmd.Dir = wt.workDir - out, err := cmd.Output() - if err != nil { - return "" - } - hash := sha256.Sum256(out) - return hex.EncodeToString(hash[:]) -} - -// checkGitChanges checks if HEAD or git index has changed and processes changes -func (wt *WorkspaceTracker) checkGitChanges(ctx context.Context) { - currentHead := wt.getHeadSHA(ctx) - currentIndexHash := wt.getGitStatusHash(ctx) - - wt.gitStateMu.RLock() - previousHead := wt.cachedHeadSHA - previousIndexHash := wt.cachedIndexHash - wt.gitStateMu.RUnlock() - - headChanged := currentHead != "" && currentHead != previousHead - indexChanged := currentIndexHash != "" && currentIndexHash != previousIndexHash - - // If neither HEAD nor index changed, nothing to do - if !headChanged && !indexChanged { - return - } - - // Update cached index hash (HEAD will be updated below if it changed) - if indexChanged && !headChanged { - wt.gitStateMu.Lock() - wt.cachedIndexHash = currentIndexHash - wt.gitStateMu.Unlock() - - wt.logger.Debug("git index changed (staging/unstaging detected)") - wt.updateGitStatus(ctx) - return - } - - // HEAD changed - handle normally - if !headChanged { - return - } - - wt.logger.Info("git HEAD changed, syncing", - zap.String("previous", previousHead), - zap.String("current", currentHead)) - - // Update cached HEAD and index hash - wt.gitStateMu.Lock() - wt.cachedHeadSHA = currentHead - wt.cachedIndexHash = currentIndexHash - wt.gitStateMu.Unlock() - - // Check if history was rewritten (reset, rebase, amend, etc.) - // There are three cases: - // 1. HEAD moved backward: currentHead is an ancestor of previousHead (e.g., git reset HEAD~1) - // 2. History rewritten: previousHead is NOT reachable from currentHead (e.g., git rebase -i, git commit --amend) - // 3. HEAD moved forward: previousHead IS an ancestor of currentHead (normal commits) - if previousHead != "" { - if wt.isAncestor(ctx, currentHead, previousHead) { - // Case 1: HEAD moved backward - emit reset notification - wt.logger.Info("detected git reset (HEAD moved backward)", - zap.String("previous", previousHead), - zap.String("current", currentHead)) - wt.notifyWorkspaceStreamGitReset(&types.GitResetNotification{ - Timestamp: time.Now(), - PreviousHead: previousHead, - CurrentHead: currentHead, - }) - } else if !wt.isAncestor(ctx, previousHead, currentHead) { - // Case 2: History was rewritten - previousHead is not reachable from currentHead - // This happens with interactive rebase, commit amend, etc. - wt.logger.Info("detected git history rewrite (previous HEAD not reachable)", - zap.String("previous", previousHead), - zap.String("current", currentHead)) - wt.notifyWorkspaceStreamGitReset(&types.GitResetNotification{ - Timestamp: time.Now(), - PreviousHead: previousHead, - CurrentHead: currentHead, - }) - } else { - // Case 3: HEAD moved forward normally - get new commits - commits := wt.getCommitsSince(ctx, previousHead) - - // Filter out commits that are already on remote branches. - // This prevents recording upstream commits as session commits when - // the user pulls/rebases onto a remote branch (e.g., git reset --hard main). - localCommits := wt.filterLocalCommits(ctx, commits) - - for _, commit := range localCommits { - wt.notifyWorkspaceStreamGitCommit(commit) - } - if len(localCommits) > 0 { - wt.logger.Info("detected new commits via polling", - zap.Int("count", len(localCommits))) - } - } - } - - // Update and broadcast git status - wt.updateGitStatus(ctx) -} - -// isAncestor checks if commit1 is an ancestor of commit2 -func (wt *WorkspaceTracker) isAncestor(ctx context.Context, commit1, commit2 string) bool { - cmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", commit1, commit2) - cmd.Dir = wt.workDir - err := cmd.Run() - // Exit code 0 means commit1 IS an ancestor of commit2 - // Exit code 1 means commit1 is NOT an ancestor of commit2 - return err == nil -} - -// isOnRemote checks if a commit is reachable from any remote tracking branch. -// This is used to filter out upstream commits that came from a pull/fetch, -// as opposed to commits made locally in the session. -func (wt *WorkspaceTracker) isOnRemote(ctx context.Context, commitSHA string) bool { - // Use git branch -r --contains to check if commit is on any remote branch - cmd := exec.CommandContext(ctx, "git", "branch", "-r", "--contains", commitSHA) - cmd.Dir = wt.workDir - out, err := cmd.Output() - if err != nil { - // If the command fails, assume it's not on remote (safer default) - return false - } - // If output is non-empty, commit is reachable from at least one remote branch - return strings.TrimSpace(string(out)) != "" -} - -// filterLocalCommits filters out commits that are already on remote branches. -// This ensures we only report commits made locally in the session, not upstream commits -// that came from pulling/rebasing onto a remote branch. -func (wt *WorkspaceTracker) filterLocalCommits(ctx context.Context, commits []*types.GitCommitNotification) []*types.GitCommitNotification { - if len(commits) == 0 { - return commits - } - - localCommits := make([]*types.GitCommitNotification, 0, len(commits)) - for _, commit := range commits { - if !wt.isOnRemote(ctx, commit.CommitSHA) { - localCommits = append(localCommits, commit) - } else { - wt.logger.Debug("skipping upstream commit (already on remote)", - zap.String("sha", commit.CommitSHA), - zap.String("message", commit.Message)) - } - } - - if skipped := len(commits) - len(localCommits); skipped > 0 { - wt.logger.Info("filtered upstream commits", - zap.Int("total", len(commits)), - zap.Int("skipped", skipped), - zap.Int("local", len(localCommits))) - } - - return localCommits -} - -// getCommitsSince returns commits from baseCommit (exclusive) to HEAD (inclusive) -func (wt *WorkspaceTracker) getCommitsSince(ctx context.Context, baseCommit string) []*types.GitCommitNotification { - // Get list of commits with metadata - // Format: SHA|ParentSHA|AuthorName|AuthorEmail|Subject|AuthorDateISO - cmd := exec.CommandContext(ctx, "git", "log", - "--format=%H|%P|%an|%ae|%s|%aI", - baseCommit+"..HEAD") - cmd.Dir = wt.workDir - out, err := cmd.Output() - if err != nil { - wt.logger.Debug("failed to get commits since base", - zap.String("base", baseCommit), - zap.Error(err)) - return nil - } - - output := strings.TrimSpace(string(out)) - if output == "" { - return nil - } - - lines := strings.Split(output, "\n") - commits := make([]*types.GitCommitNotification, 0, len(lines)) - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.SplitN(line, "|", 6) - if len(parts) < 6 { - continue - } - - sha := parts[0] - parentSHA := parts[1] - // Handle multiple parents (merge commits) - just take the first - if idx := strings.Index(parentSHA, " "); idx > 0 { - parentSHA = parentSHA[:idx] - } - - committedAt, err := time.Parse(time.RFC3339, parts[5]) - if err != nil { - committedAt = time.Now().UTC() - } - - // Get stats for this commit - filesChanged, insertions, deletions := wt.getCommitStats(ctx, sha) - - commits = append(commits, &types.GitCommitNotification{ - Timestamp: time.Now(), - CommitSHA: sha, - ParentSHA: parentSHA, - AuthorName: parts[2], - AuthorEmail: parts[3], - Message: parts[4], - FilesChanged: filesChanged, - Insertions: insertions, - Deletions: deletions, - CommittedAt: committedAt, - }) - } - - return commits -} - -// getCommitStats returns the number of files changed, insertions, and deletions for a commit -func (wt *WorkspaceTracker) getCommitStats(ctx context.Context, sha string) (filesChanged, insertions, deletions int) { - cmd := exec.CommandContext(ctx, "git", "show", "--stat", "--format=", sha) - cmd.Dir = wt.workDir - out, err := cmd.Output() - if err != nil { - return 0, 0, 0 - } - - // Parse the last line which contains summary like "3 files changed, 10 insertions(+), 5 deletions(-)" - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - if len(lines) == 0 { - return 0, 0, 0 - } - - summary := lines[len(lines)-1] - // Simple parsing - look for numbers before keywords - parts := strings.Fields(summary) - for i, part := range parts { - if strings.Contains(part, "file") && i > 0 { - _, _ = fmt.Sscanf(parts[i-1], "%d", &filesChanged) - } - if strings.Contains(part, "insertion") && i > 0 { - _, _ = fmt.Sscanf(parts[i-1], "%d", &insertions) - } - if strings.Contains(part, "deletion") && i > 0 { - _, _ = fmt.Sscanf(parts[i-1], "%d", &deletions) - } - } - - return filesChanged, insertions, deletions -} - -// updateGitStatus updates the git status -func (wt *WorkspaceTracker) updateGitStatus(ctx context.Context) { - status, err := wt.getGitStatus(ctx) - if err != nil { - wt.logger.Warn("updateGitStatus: getGitStatus failed", zap.Error(err)) - return - } - - wt.mu.Lock() - wt.currentStatus = status - wt.mu.Unlock() - - // Notify workspace stream subscribers - wt.notifyWorkspaceStreamGitStatus(status) -} - -// RefreshGitStatus forces a git status refresh and notifies subscribers. -// Useful after index-only changes (stage/unstage) that the file watcher won't detect. -func (wt *WorkspaceTracker) RefreshGitStatus(ctx context.Context) { - wt.updateGitStatus(ctx) -} - -// getGitStatus retrieves the current git status -func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUpdate, error) { - update := types.GitStatusUpdate{ - Timestamp: time.Now(), - Modified: []string{}, - Added: []string{}, - Deleted: []string{}, - Untracked: []string{}, - Renamed: []string{}, - Files: make(map[string]types.FileInfo), - } - - // Get current branch - branchCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") - branchCmd.Dir = wt.workDir - branchOut, err := branchCmd.Output() - if err != nil { - return update, err - } - update.Branch = strings.TrimSpace(string(branchOut)) - - // Get remote branch - remoteCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "@{upstream}") - remoteCmd.Dir = wt.workDir - if remoteOut, err := remoteCmd.Output(); err == nil { - update.RemoteBranch = strings.TrimSpace(string(remoteOut)) - } - - // Get current HEAD commit SHA - headCmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") - headCmd.Dir = wt.workDir - if headOut, err := headCmd.Output(); err == nil { - update.HeadCommit = strings.TrimSpace(string(headOut)) - } - - // Get base commit SHA (base branch HEAD) - // Use remote branch if available, fall back to main/master - baseBranch := update.RemoteBranch - if baseBranch == "" { - // Try main first, then master - baseBranch = "main" - checkCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "main") - checkCmd.Dir = wt.workDir - if err := checkCmd.Run(); err != nil { - baseBranch = "master" - } - } - baseCmd := exec.CommandContext(ctx, "git", "rev-parse", baseBranch) - baseCmd.Dir = wt.workDir - if baseOut, err := baseCmd.Output(); err == nil { - update.BaseCommit = strings.TrimSpace(string(baseOut)) - } - - // Get ahead/behind counts - // Use remote branch if available, otherwise fall back to origin/main or origin/master - // This handles worktree branches that don't have an upstream tracking branch set - compareRef := update.RemoteBranch - if compareRef == "" { - // Try origin/main first, then origin/master - checkCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "origin/main") - checkCmd.Dir = wt.workDir - if err := checkCmd.Run(); err == nil { - compareRef = "origin/main" - } else { - checkCmd2 := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "origin/master") - checkCmd2.Dir = wt.workDir - if err := checkCmd2.Run(); err == nil { - compareRef = "origin/master" - } - } - } - if compareRef != "" { - countCmd := exec.CommandContext(ctx, "git", "rev-list", "--left-right", "--count", update.Branch+"..."+compareRef) - countCmd.Dir = wt.workDir - if countOut, err := countCmd.Output(); err == nil { - parts := strings.Fields(string(countOut)) - if len(parts) == 2 { - update.Ahead, _ = strconv.Atoi(parts[0]) - update.Behind, _ = strconv.Atoi(parts[1]) - } - } - } - - // Get file status using git status --porcelain --untracked-files=all - // --untracked-files=all shows all files in untracked directories, not just the directory name - statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--untracked-files=all") - statusCmd.Dir = wt.workDir - statusOut, err := statusCmd.Output() - if err != nil { - return update, err - } - - // Parse porcelain output - // Git status --porcelain format: XY filename - // X = index (staged) status, Y = working tree (unstaged) status - // ' ' = unmodified, M = modified, A = added, D = deleted, R = renamed, ? = untracked - lines := strings.Split(string(statusOut), "\n") - for _, line := range lines { - if len(line) < 3 { - continue - } - - indexStatus := line[0] // Staged status (X) - workTreeStatus := line[1] // Unstaged status (Y) - filePath := strings.TrimSpace(line[3:]) - - fileInfo := types.FileInfo{ - Path: filePath, - } - - // Determine staged status based on index and worktree status. - // A file's change is "staged" if the relevant change is in the index. - // If there's a worktree change (like deletion), that change is unstaged. - // - // Examples: - // "M " = modified and staged (Staged=true) - // " M" = modified but unstaged (Staged=false) - // "MM" = modified staged + more unstaged modifications (Staged=false for the current state) - // "A " = added and staged (Staged=true) - // "AD" = added to index but deleted in worktree - deletion is unstaged (Staged=false) - // "D " = deleted and staged (Staged=true) - // " D" = deleted but unstaged (Staged=false) - - // Parse status - prioritize worktree changes as they represent the current state - switch { - case indexStatus == '?' && workTreeStatus == '?': - fileInfo.Status = "untracked" - fileInfo.Staged = false - update.Untracked = append(update.Untracked, filePath) - case workTreeStatus == 'D': - // File deleted in worktree - this is an unstaged deletion - // Even if there were staged changes before (A, M), the deletion is unstaged - fileInfo.Status = "deleted" - fileInfo.Staged = false - update.Deleted = append(update.Deleted, filePath) - case indexStatus == 'D': - // File deleted and staged (no worktree status means deletion is complete) - fileInfo.Status = "deleted" - fileInfo.Staged = true - update.Deleted = append(update.Deleted, filePath) - case workTreeStatus == 'M': - // Modified in worktree - unstaged modification - fileInfo.Status = "modified" - fileInfo.Staged = false - update.Modified = append(update.Modified, filePath) - case indexStatus == 'M': - // Modified and staged (no worktree changes) - fileInfo.Status = "modified" - fileInfo.Staged = true - update.Modified = append(update.Modified, filePath) - case indexStatus == 'A': - // Added and staged - fileInfo.Status = "added" - fileInfo.Staged = true - update.Added = append(update.Added, filePath) - case indexStatus == 'R': - fileInfo.Status = "renamed" - fileInfo.Staged = true - // Renamed files have format "old -> new" - if idx := strings.Index(filePath, " -> "); idx != -1 { - fileInfo.OldPath = filePath[:idx] - fileInfo.Path = filePath[idx+4:] - } - update.Renamed = append(update.Renamed, filePath) - } - - update.Files[filePath] = fileInfo - } - - // Enrich file info with diff data (additions, deletions, and actual diff content) - wt.enrichWithDiffData(ctx, &update) - - return update, nil -} - -// enrichWithDiffData adds diff information (additions, deletions, diff content) to file info -func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *types.GitStatusUpdate) { - // Determine the base ref to compare against - // Use remote branch if available, otherwise use HEAD - baseRef := "HEAD" - if update.RemoteBranch != "" { - baseRef = update.RemoteBranch - } - - // Get numstat for additions/deletions count (compare against base branch) - numstatCmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef) - numstatCmd.Dir = wt.workDir - numstatOut, err := numstatCmd.Output() - if err != nil { - wt.logger.Debug("failed to get numstat", zap.Error(err)) - return - } - - // Parse numstat output and update file info - lines := strings.Split(string(numstatOut), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.Fields(line) - if len(parts) < 3 { - continue - } - - additions, _ := strconv.Atoi(parts[0]) - deletions, _ := strconv.Atoi(parts[1]) - filePath := parts[2] - - // Only update file info if it exists in status (uncommitted changes). - // Files that appear in diff but not in status are committed changes - we don't - // add them to the Files map as that would make git status show already-committed files. - if fileInfo, exists := update.Files[filePath]; exists { - fileInfo.Additions = additions - fileInfo.Deletions = deletions - - // Get the actual diff content for this file (compare against base branch) - diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath) - diffCmd.Dir = wt.workDir - if diffOut, err := diffCmd.Output(); err == nil { - fileInfo.Diff = string(diffOut) - } - - update.Files[filePath] = fileInfo - } - // NOTE: We intentionally do NOT add files that are in the diff but not in git status. - // Those are committed changes, not uncommitted changes. The UI should only show - // uncommitted changes in the "files changed" count. - } - - // For staged files that don't have unstaged changes, we need to get the diff from the index. - // The first diff (git diff baseRef) shows worktree vs baseRef, but if a file is staged - // and has no additional unstaged changes, its diff won't appear there. - // We use git diff --cached baseRef to get the staged diff content for such files, - // using the same baseRef for consistency. - stagedCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--numstat", baseRef) - stagedCmd.Dir = wt.workDir - stagedOut, err := stagedCmd.Output() - if err != nil { - return - } - - lines = strings.Split(string(stagedOut), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.Fields(line) - if len(parts) < 3 { - continue - } - - additions, _ := strconv.Atoi(parts[0]) - deletions, _ := strconv.Atoi(parts[1]) - filePath := parts[2] - - // Update existing file info if it exists - if fileInfo, exists := update.Files[filePath]; exists { - // Only set additions/deletions if they weren't already set by the first diff. - // This prevents double-counting when changes appear in both diffs. - if fileInfo.Additions == 0 && fileInfo.Deletions == 0 { - fileInfo.Additions = additions - fileInfo.Deletions = deletions - } - - // Get the staged diff content if we don't have diff content yet - // Use the same baseRef for consistency with unstaged files - if fileInfo.Diff == "" { - diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", baseRef, "--", filePath) - diffCmd.Dir = wt.workDir - if diffOut, err := diffCmd.Output(); err == nil { - fileInfo.Diff = string(diffOut) - } - } - - update.Files[filePath] = fileInfo - } - } - - // For untracked files, show the entire file content as "added" - for filePath, fileInfo := range update.Files { - if fileInfo.Status == "untracked" { - // Show file content as diff (all additions) - catCmd := exec.CommandContext(ctx, "cat", filePath) - catCmd.Dir = wt.workDir - if catOut, err := catCmd.Output(); err == nil { - content := string(catOut) - lines := strings.Split(content, "\n") - fileInfo.Additions = len(lines) - fileInfo.Deletions = 0 - - // Format as a proper git diff with all required headers - // The @git-diff-view/react library requires the full git diff format - var diffBuilder strings.Builder - diffBuilder.WriteString("diff --git a/" + filePath + " b/" + filePath + "\n") - diffBuilder.WriteString("new file mode 100644\n") - diffBuilder.WriteString("index 0000000..0000000\n") - diffBuilder.WriteString("--- /dev/null\n") - diffBuilder.WriteString("+++ b/" + filePath + "\n") - diffBuilder.WriteString("@@ -0,0 +1," + strconv.Itoa(len(lines)) + " @@\n") - for _, line := range lines { - diffBuilder.WriteString("+" + line + "\n") - } - fileInfo.Diff = diffBuilder.String() - - update.Files[filePath] = fileInfo - } - } - } -} - -// updateFiles updates the file listing -func (wt *WorkspaceTracker) updateFiles(ctx context.Context) { - files, err := wt.getFileList(ctx) - if err != nil { - wt.logger.Debug("failed to get file list", zap.Error(err)) - return - } - - wt.mu.Lock() - wt.currentFiles = files - wt.mu.Unlock() -} - -// getFileList retrieves the list of files in the workspace -func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpdate, error) { - update := types.FileListUpdate{ - Timestamp: time.Now(), - Files: []types.FileEntry{}, - } - - // Use git ls-files to get tracked files - cmd := exec.CommandContext(ctx, "git", "ls-files") - cmd.Dir = wt.workDir - out, err := cmd.Output() - if err != nil { - return update, err - } - - lines := strings.Split(string(out), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - update.Files = append(update.Files, types.FileEntry{ - Path: line, - IsDir: false, - }) - } - - return update, nil -} - -// Unified workspace stream subscriber management methods - -// SubscribeWorkspaceStream creates a new unified workspace stream subscriber -// and sends current git status and file list immediately -func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber { - sub := make(types.WorkspaceStreamSubscriber, 100) - - 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() - currentStatus := wt.currentStatus - wt.mu.RUnlock() - - if currentStatus.Timestamp.IsZero() { - currentStatus.Timestamp = time.Now() - } - - // Send git status - select { - case sub <- types.NewWorkspaceGitStatus(¤tStatus): - default: - } - - return sub -} - -// UnsubscribeWorkspaceStream removes and closes a workspace stream subscriber -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 - -// notifyWorkspaceStreamGitStatus sends git status to all workspace stream subscribers -func (wt *WorkspaceTracker) notifyWorkspaceStreamGitStatus(update types.GitStatusUpdate) { - wt.workspaceSubMu.RLock() - defer wt.workspaceSubMu.RUnlock() - - msg := types.NewWorkspaceGitStatus(&update) - for sub := range wt.workspaceStreamSubscribers { - select { - case sub <- msg: - default: - // Subscriber is slow, skip - } - } -} - -// notifyWorkspaceStreamGitCommit sends git commit notification to all workspace stream subscribers -func (wt *WorkspaceTracker) notifyWorkspaceStreamGitCommit(commit *types.GitCommitNotification) { - wt.workspaceSubMu.RLock() - defer wt.workspaceSubMu.RUnlock() - - msg := types.NewWorkspaceGitCommit(commit) - for sub := range wt.workspaceStreamSubscribers { - select { - case sub <- msg: - default: - // Subscriber is slow, skip - } - } -} - -// NotifyGitCommit notifies all subscribers about a new git commit. -// It also updates the cached HEAD SHA to prevent polling from re-detecting the same commit. -func (wt *WorkspaceTracker) NotifyGitCommit(commit *types.GitCommitNotification) { - // Update cached HEAD to the new commit SHA so polling doesn't re-detect it - if commit.CommitSHA != "" { - wt.gitStateMu.Lock() - wt.cachedHeadSHA = commit.CommitSHA - wt.gitStateMu.Unlock() - } - - wt.notifyWorkspaceStreamGitCommit(commit) -} - -// notifyWorkspaceStreamGitReset sends git reset notification to all workspace stream subscribers -func (wt *WorkspaceTracker) notifyWorkspaceStreamGitReset(reset *types.GitResetNotification) { - wt.workspaceSubMu.RLock() - defer wt.workspaceSubMu.RUnlock() - - msg := types.NewWorkspaceGitReset(reset) - for sub := range wt.workspaceStreamSubscribers { - select { - case sub <- msg: - default: - // Subscriber is slow, skip - } - } -} - -// addPendingChange records a specific file change and triggers a debounced update. -func (wt *WorkspaceTracker) addPendingChange(relPath, operation string) { - wt.pendingChangesMu.Lock() - wt.pendingChanges = append(wt.pendingChanges, types.FileChangeNotification{ - Timestamp: time.Now(), - Path: relPath, - Operation: operation, - }) - wt.pendingChangesMu.Unlock() - - // Trigger debounced update (non-blocking) - select { - case wt.fsChangeTrigger <- struct{}{}: - default: - } -} - -// emitFileChanges sends accumulated file changes to subscribers. -// Falls back to a single generic refresh when there are too many changes or none. -func (wt *WorkspaceTracker) emitFileChanges(changes []types.FileChangeNotification) { - const maxSpecificChanges = 50 - if len(changes) == 0 || len(changes) > maxSpecificChanges { - wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ - Timestamp: time.Now(), - Operation: types.FileOpRefresh, - }) - return - } - for i := range changes { - wt.notifyWorkspaceStreamFileChange(changes[i]) - } -} - -// notifyWorkspaceStreamFileChange sends file change notification to all workspace stream subscribers -func (wt *WorkspaceTracker) notifyWorkspaceStreamFileChange(notification types.FileChangeNotification) { - wt.workspaceSubMu.RLock() - defer wt.workspaceSubMu.RUnlock() - - msg := types.NewWorkspaceFileChange(¬ification) - for sub := range wt.workspaceStreamSubscribers { - select { - case sub <- msg: - default: - // Subscriber is slow, skip - } - } -} - -// 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() - - for { - select { - case <-ctx.Done(): - return - case <-wt.stopCh: - return - case event, ok := <-wt.watcher.Events: - if !ok { - return - } - - // Ignore CHMOD events - permission changes don't affect file content - // and shouldn't trigger UI refreshes. This prevents loops from filesystem - // scanners, git operations, or other tools that touch file permissions. - if event.Op == fsnotify.Chmod { - continue - } - - // If a directory was created, watch it - if event.Op&fsnotify.Create == fsnotify.Create { - if info, err := os.Stat(event.Name); err == nil && info.IsDir() { - if err := wt.addDirectoryRecursive(event.Name); err != nil { - wt.logger.Debug("failed to watch new directory", zap.Error(err)) - } - } - } - - // Record specific change with relative path - relPath, relErr := filepath.Rel(wt.workDir, event.Name) - if relErr != nil { - relPath = event.Name - } - wt.addPendingChange(relPath, fsOpToString(event.Op)) - - case err, ok := <-wt.watcher.Errors: - if !ok { - return - } - wt.logger.Debug("filesystem watcher error", zap.Error(err)) - } - } -} - -// fsOpToString converts an fsnotify operation to a FileOp string constant. -func fsOpToString(op fsnotify.Op) string { - switch { - case op&fsnotify.Create != 0: - return types.FileOpCreate - case op&fsnotify.Write != 0: - return types.FileOpWrite - case op&fsnotify.Remove != 0: - return types.FileOpRemove - case op&fsnotify.Rename != 0: - return types.FileOpRename - default: - return types.FileOpRefresh - } -} - -// addDirectoryRecursive adds a directory and all its subdirectories to the watcher -func (wt *WorkspaceTracker) addDirectoryRecursive(dir string) error { - return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip hidden directories and common ignore patterns - if d.IsDir() { - name := d.Name() - if name == ".git" || name == "node_modules" || name == ".next" || name == "dist" || name == "build" { - return filepath.SkipDir - } - - // Add directory to watcher - if err := wt.watcher.Add(path); err != nil { - wt.logger.Debug("failed to watch directory", zap.String("path", path), zap.Error(err)) - } - } - - return nil - }) -} - -// GetFileTree returns the file tree for a given path and depth -func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*types.FileTreeNode, error) { - // Resolve the full path with path traversal protection - fullPath := filepath.Join(wt.workDir, filepath.Clean(reqPath)) - cleanWorkDir := filepath.Clean(wt.workDir) - if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir { - return nil, fmt.Errorf("path traversal detected") - } - - // Check if path exists - info, err := os.Stat(fullPath) - if err != nil { - return nil, fmt.Errorf("path not found: %w", err) - } - - // Build the tree - node, err := wt.buildFileTreeNode(fullPath, reqPath, info, depth, 0) - if err != nil { - return nil, err - } - - return node, nil -} - -// buildFileTreeNode recursively builds a file tree node -func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.FileInfo, maxDepth, currentDepth int) (*types.FileTreeNode, error) { - node := &types.FileTreeNode{ - Name: info.Name(), - Path: relPath, - IsDir: info.IsDir(), - Size: info.Size(), - } - - // If it's a file or we've reached max depth, return - if !info.IsDir() || (maxDepth > 0 && currentDepth >= maxDepth) { - return node, nil - } - - // Read directory contents - entries, err := os.ReadDir(fullPath) - if err != nil { - return node, nil // Return node without children on error - } - - // Build children - node.Children = make([]*types.FileTreeNode, 0, len(entries)) - for _, entry := range entries { - // Skip specific directories that should be ignored - name := entry.Name() - if name == ".git" || name == "node_modules" || name == ".next" || name == "dist" || name == "build" { - continue - } - - childFullPath := filepath.Join(fullPath, name) - childRelPath := filepath.Join(relPath, name) - - childInfo, err := entry.Info() - if err != nil { - continue - } - - childNode, err := wt.buildFileTreeNode(childFullPath, childRelPath, childInfo, maxDepth, currentDepth+1) - if err != nil { - continue - } - - node.Children = append(node.Children, childNode) - } - - return node, nil -} - -// resolveSafePath resolves reqPath to an absolute path within workDir, -// rejecting any path traversal attempts. -func (wt *WorkspaceTracker) resolveSafePath(reqPath string) (string, error) { - cleanWorkDir := filepath.Clean(wt.workDir) - cleanReqPath := filepath.Clean(reqPath) - - var fullPath string - if filepath.IsAbs(cleanReqPath) && strings.HasPrefix(cleanReqPath, cleanWorkDir+string(os.PathSeparator)) { - fullPath = cleanReqPath - } else { - fullPath = filepath.Join(wt.workDir, cleanReqPath) - } - - if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir { - return "", fmt.Errorf("path traversal detected") - } - - return fullPath, nil -} - -// GetFileContent returns the content of a file. -// If the file is not valid UTF-8, it is base64-encoded and isBinary is true. -func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, error) { - fullPath, err := wt.resolveSafePath(reqPath) - if err != nil { - return "", 0, false, err - } - - // Check if file exists and is a regular file - info, err := os.Stat(fullPath) - if err != nil { - return "", 0, false, fmt.Errorf("file not found: %w", err) - } - - if info.IsDir() { - return "", 0, false, fmt.Errorf("path is a directory, not a file") - } - - // Check file size (limit to 10MB) - const maxFileSize = 10 * 1024 * 1024 - if info.Size() > maxFileSize { - return "", info.Size(), false, fmt.Errorf("file too large (max 10MB)") - } - - // Read file content - file, err := os.Open(fullPath) - if err != nil { - return "", 0, false, fmt.Errorf("failed to open file: %w", err) - } - defer func() { - _ = file.Close() - }() - - content, err := io.ReadAll(file) - if err != nil { - return "", 0, false, fmt.Errorf("failed to read file: %w", err) - } - - // Detect binary: if content is not valid UTF-8, base64-encode it - if !utf8.Valid(content) { - encoded := base64.StdEncoding.EncodeToString(content) - return encoded, info.Size(), true, nil - } - - return string(content), info.Size(), false, nil -} - -// ApplyFileDiff applies a unified diff to a file with conflict detection -// Uses git apply for reliable, battle-tested patch application -func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, originalHash string) (string, error) { - fullPath, err := wt.resolveSafePath(reqPath) - if err != nil { - return "", err - } - - cleanWorkDir := filepath.Clean(wt.workDir) - - // Read current file content - currentContent, _, _, err := wt.GetFileContent(reqPath) - if err != nil { - return "", fmt.Errorf("failed to read current file: %w", err) - } - - // Calculate hash of current content for conflict detection - currentHash := calculateSHA256(currentContent) - if originalHash != "" && currentHash != originalHash { - return "", fmt.Errorf("conflict detected: file has been modified (expected hash %s, got %s)", originalHash, currentHash) - } - - // Write diff to a temporary patch file - patchFile := filepath.Join(wt.workDir, ".kandev-patch.tmp") - err = os.WriteFile(patchFile, []byte(unifiedDiff), 0644) - if err != nil { - return "", fmt.Errorf("failed to write patch file: %w", err) - } - defer func() { - _ = os.Remove(patchFile) // Best effort cleanup - }() - - // Use git apply to apply the patch directly to the file - // This is much more reliable than custom diff application - cmd := exec.Command("git", "apply", "-p0", "--unidiff-zero", "--whitespace=nowarn", patchFile) - cmd.Dir = wt.workDir - - output, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("git apply failed: %w\nOutput: %s", err, string(output)) - } - - // Read the updated content - updatedContent, err := os.ReadFile(fullPath) - if err != nil { - return "", fmt.Errorf("failed to read updated file: %w", err) - } - - // Calculate new hash - newHash := calculateSHA256(string(updatedContent)) - - // Notify with the relative path - relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) - wt.addPendingChange(relPath, types.FileOpWrite) - - wt.logger.Debug("applied file diff using git apply", - zap.String("path", relPath), - zap.String("old_hash", currentHash), - zap.String("new_hash", newHash), - ) - - return newHash, nil -} - -// CreateFile creates a new file in the workspace -func (wt *WorkspaceTracker) CreateFile(reqPath string) error { - fullPath, err := wt.resolveSafePath(reqPath) - if err != nil { - return err - } - - // Create intermediate directories - dir := filepath.Dir(fullPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create directories: %w", err) - } - - // Atomically create the file, failing if it already exists - f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - if err != nil { - if os.IsExist(err) { - return fmt.Errorf("file already exists: %s", reqPath) - } - return fmt.Errorf("failed to create file: %w", err) - } - _ = f.Close() - - // Notify with the relative path - cleanWorkDir := filepath.Clean(wt.workDir) - relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) - wt.addPendingChange(relPath, types.FileOpCreate) - - return nil -} - -func (wt *WorkspaceTracker) DeleteFile(reqPath string) error { - fullPath, err := wt.resolveSafePath(reqPath) - if err != nil { - return err - } - - // Check if file exists - info, err := os.Stat(fullPath) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("file does not exist: %s", reqPath) - } - return fmt.Errorf("failed to stat file: %w", err) - } - - // Don't allow deleting directories - if info.IsDir() { - return fmt.Errorf("cannot delete directory: %s", reqPath) - } - - // Delete the file - if err := os.Remove(fullPath); err != nil { - return fmt.Errorf("failed to delete file: %w", err) - } - - // Notify with the relative path - cleanWorkDir := filepath.Clean(wt.workDir) - relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) - wt.addPendingChange(relPath, types.FileOpRemove) - - return nil -} - -// calculateSHA256 calculates the SHA256 hash of a string -func calculateSHA256(content string) string { - hash := sha256.Sum256([]byte(content)) - return hex.EncodeToString(hash[:]) -} - -// scoredMatch holds a file path and its match score for sorting -type scoredMatch struct { - path string - score int -} - -// SearchFiles searches for files matching the query string. -// It uses fuzzy matching with scoring based on how well the query matches. -func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { - if query == "" { - return []string{} - } - if limit <= 0 { - limit = 20 - } - - query = strings.ToLower(query) - var matches []scoredMatch - - wt.mu.RLock() - files := wt.currentFiles.Files - wt.mu.RUnlock() - - for _, file := range files { - path := file.Path - lowerPath := strings.ToLower(path) - name := filepath.Base(lowerPath) - - score := 0 - if name == query { - score = 100 // Exact filename match - } else if strings.HasPrefix(name, query) { - score = 75 // Filename starts with query - } else if strings.Contains(name, query) { - score = 50 // Filename contains query - } else if strings.Contains(lowerPath, query) { - score = 25 // Path contains query - } - - if score > 0 { - matches = append(matches, scoredMatch{path: path, score: score}) - } - } - - // Sort by score descending - sort.Slice(matches, func(i, j int) bool { - if matches[i].score != matches[j].score { - return matches[i].score > matches[j].score - } - // Secondary sort by path length (shorter paths first) - return len(matches[i].path) < len(matches[j].path) - }) - - // Return top limit results - result := make([]string, 0, limit) - for i := 0; i < len(matches) && i < limit; i++ { - result = append(result, matches[i].path) - } - - return result -} diff --git a/apps/backend/internal/agentctl/server/shell/session_test.go b/apps/backend/internal/agentctl/server/shell/session_test.go index f419f587d2..899b05b92f 100644 --- a/apps/backend/internal/agentctl/server/shell/session_test.go +++ b/apps/backend/internal/agentctl/server/shell/session_test.go @@ -42,28 +42,35 @@ func TestDetectShell(t *testing.T) { t.Error("detectShell returned empty shell") } - // Verify detected shell exists on Unix systems - if runtime.GOOS != "windows" { - if _, err := os.Stat(shell); err != nil { - // May be in PATH, try LookPath - if _, err := os.Stat(shell); err != nil && shell != os.Getenv("SHELL") { - t.Logf("Warning: detected shell %q may not exist: %v", shell, err) - } - } - // Unix shells should have -l flag for login shell - if len(args) > 0 && args[0] != "-l" { - t.Errorf("expected Unix shell args to contain '-l', got %v", args) - } - } else { - // Windows should detect cmd.exe, powershell.exe, or pwsh.exe - validShells := map[string]bool{ - "cmd.exe": true, - "powershell.exe": true, - "pwsh.exe": true, - } - if !validShells[shell] { - t.Errorf("unexpected Windows shell: %q", shell) - } + if runtime.GOOS == "windows" { + assertWindowsShell(t, shell) + return + } + assertUnixShell(t, shell, args) +} + +// assertUnixShell verifies Unix-specific shell detection results. +func assertUnixShell(t *testing.T, shell string, args []string) { + t.Helper() + if _, err := os.Stat(shell); err != nil && shell != os.Getenv("SHELL") { + t.Logf("Warning: detected shell %q may not exist: %v", shell, err) + } + // Unix shells should have -l flag for login shell + if len(args) > 0 && args[0] != "-l" { + t.Errorf("expected Unix shell args to contain '-l', got %v", args) + } +} + +// assertWindowsShell verifies Windows-specific shell detection results. +func assertWindowsShell(t *testing.T, shell string) { + t.Helper() + validShells := map[string]bool{ + "cmd.exe": true, + "powershell.exe": true, + "pwsh.exe": true, + } + if !validShells[shell] { + t.Errorf("unexpected Windows shell: %q", shell) } } diff --git a/apps/backend/internal/analytics/handlers/stats_handlers.go b/apps/backend/internal/analytics/handlers/stats_handlers.go index d729c876ac..8dc9af868c 100644 --- a/apps/backend/internal/analytics/handlers/stats_handlers.go +++ b/apps/backend/internal/analytics/handlers/stats_handlers.go @@ -1,12 +1,15 @@ package handlers import ( + "context" + "fmt" "net/http" "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/analytics/dto" + "github.com/kandev/kandev/internal/analytics/models" "github.com/kandev/kandev/internal/analytics/repository" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" @@ -37,6 +40,17 @@ func (h *StatsHandlers) registerHTTP(router *gin.Engine) { api.GET("/workspaces/:id/stats", h.httpGetStats) } +// rawStats holds all raw stats fetched from the repository before DTO conversion. +type rawStats struct { + globalStats *models.GlobalStats + taskStats []*models.TaskStats + dailyActivity []*models.DailyActivity + completedActivity []*models.CompletedTaskActivity + agentUsage []*models.AgentUsage + repoStats []*models.RepositoryStats + gitStats *models.GitStats +} + func (h *StatsHandlers) httpGetStats(c *gin.Context) { workspaceID := c.Param("id") if workspaceID == "" { @@ -47,38 +61,86 @@ func (h *StatsHandlers) httpGetStats(c *gin.Context) { rangeKey := c.Query("range") start, days := parseStatsRange(rangeKey) - globalStats, err := h.repo.GetGlobalStats(c.Request.Context(), workspaceID, start) + raw, err := h.fetchStats(c.Request.Context(), workspaceID, start, days) if err != nil { - h.logger.Error("failed to get global stats", zap.String("workspace_id", workspaceID), zap.Error(err)) + h.logger.Error("failed to get stats", zap.String("workspace_id", workspaceID), zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) return } - taskStats, err := h.repo.GetTaskStats(c.Request.Context(), workspaceID, start) - if err != nil { - h.logger.Error("failed to get task stats", zap.String("workspace_id", workspaceID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) - return + response := dto.StatsResponse{ + Global: dto.GlobalStatsDTO{ + TotalTasks: raw.globalStats.TotalTasks, + CompletedTasks: raw.globalStats.CompletedTasks, + InProgressTasks: raw.globalStats.InProgressTasks, + TotalSessions: raw.globalStats.TotalSessions, + TotalTurns: raw.globalStats.TotalTurns, + TotalMessages: raw.globalStats.TotalMessages, + TotalUserMessages: raw.globalStats.TotalUserMessages, + TotalToolCalls: raw.globalStats.TotalToolCalls, + TotalDurationMs: raw.globalStats.TotalDurationMs, + AvgTurnsPerTask: raw.globalStats.AvgTurnsPerTask, + AvgMessagesPerTask: raw.globalStats.AvgMessagesPerTask, + AvgDurationMsPerTask: raw.globalStats.AvgDurationMsPerTask, + }, + TaskStats: taskStatsToDTOs(raw.taskStats), + DailyActivity: dailyActivityToDTOs(raw.dailyActivity), + CompletedActivity: completedActivityToDTOs(raw.completedActivity), + AgentUsage: agentUsageToDTOs(raw.agentUsage), + RepositoryStats: repositoryStatsToDTOs(raw.repoStats), + GitStats: dto.GitStatsDTO{ + TotalCommits: raw.gitStats.TotalCommits, + TotalFilesChanged: raw.gitStats.TotalFilesChanged, + TotalInsertions: raw.gitStats.TotalInsertions, + TotalDeletions: raw.gitStats.TotalDeletions, + }, } - // Get daily activity for the selected range - dailyActivity, err := h.repo.GetDailyActivity(c.Request.Context(), workspaceID, days) - if err != nil { - h.logger.Error("failed to get daily activity", zap.String("workspace_id", workspaceID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) - return - } + c.JSON(http.StatusOK, response) +} - // Get completed task activity for the selected range - completedActivity, err := h.repo.GetCompletedTaskActivity(c.Request.Context(), workspaceID, days) +func (h *StatsHandlers) fetchStats(ctx context.Context, workspaceID string, start *time.Time, days int) (*rawStats, error) { + globalStats, err := h.repo.GetGlobalStats(ctx, workspaceID, start) if err != nil { - h.logger.Error("failed to get completed task activity", zap.String("workspace_id", workspaceID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) - return + return nil, fmt.Errorf("global stats: %w", err) } + taskStats, err := h.repo.GetTaskStats(ctx, workspaceID, start) + if err != nil { + return nil, fmt.Errorf("task stats: %w", err) + } + dailyActivity, err := h.repo.GetDailyActivity(ctx, workspaceID, days) + if err != nil { + return nil, fmt.Errorf("daily activity: %w", err) + } + completedActivity, err := h.repo.GetCompletedTaskActivity(ctx, workspaceID, days) + if err != nil { + return nil, fmt.Errorf("completed activity: %w", err) + } + agentUsage, err := h.repo.GetAgentUsage(ctx, workspaceID, 5, start) + if err != nil { + return nil, fmt.Errorf("agent usage: %w", err) + } + repoStats, err := h.repo.GetRepositoryStats(ctx, workspaceID, start) + if err != nil { + return nil, fmt.Errorf("repository stats: %w", err) + } + gitStats, err := h.repo.GetGitStats(ctx, workspaceID, start) + if err != nil { + return nil, fmt.Errorf("git stats: %w", err) + } + return &rawStats{ + globalStats: globalStats, + taskStats: taskStats, + dailyActivity: dailyActivity, + completedActivity: completedActivity, + agentUsage: agentUsage, + repoStats: repoStats, + gitStats: gitStats, + }, nil +} - // Convert to DTOs - taskStatsDTOs := make([]dto.TaskStatsDTO, 0, len(taskStats)) +func taskStatsToDTOs(taskStats []*models.TaskStats) []dto.TaskStatsDTO { + result := make([]dto.TaskStatsDTO, 0, len(taskStats)) for _, ts := range taskStats { taskDTO := dto.TaskStatsDTO{ TaskID: ts.TaskID, @@ -98,38 +160,39 @@ func (h *StatsHandlers) httpGetStats(c *gin.Context) { formatted := ts.CompletedAt.UTC().Format(time.RFC3339) taskDTO.CompletedAt = &formatted } - taskStatsDTOs = append(taskStatsDTOs, taskDTO) + result = append(result, taskDTO) } + return result +} - dailyActivityDTOs := make([]dto.DailyActivityDTO, 0, len(dailyActivity)) - for _, da := range dailyActivity { - dailyActivityDTOs = append(dailyActivityDTOs, dto.DailyActivityDTO{ +func dailyActivityToDTOs(items []*models.DailyActivity) []dto.DailyActivityDTO { + result := make([]dto.DailyActivityDTO, 0, len(items)) + for _, da := range items { + result = append(result, dto.DailyActivityDTO{ Date: da.Date, TurnCount: da.TurnCount, MessageCount: da.MessageCount, TaskCount: da.TaskCount, }) } + return result +} - completedActivityDTOs := make([]dto.CompletedTaskActivityDTO, 0, len(completedActivity)) - for _, ca := range completedActivity { - completedActivityDTOs = append(completedActivityDTOs, dto.CompletedTaskActivityDTO{ +func completedActivityToDTOs(items []*models.CompletedTaskActivity) []dto.CompletedTaskActivityDTO { + result := make([]dto.CompletedTaskActivityDTO, 0, len(items)) + for _, ca := range items { + result = append(result, dto.CompletedTaskActivityDTO{ Date: ca.Date, CompletedTasks: ca.CompletedTasks, }) } + return result +} - // Get agent usage (top 5) - agentUsage, err := h.repo.GetAgentUsage(c.Request.Context(), workspaceID, 5, start) - if err != nil { - h.logger.Error("failed to get agent usage", zap.String("workspace_id", workspaceID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) - return - } - - agentUsageDTOs := make([]dto.AgentUsageDTO, 0, len(agentUsage)) - for _, au := range agentUsage { - agentUsageDTOs = append(agentUsageDTOs, dto.AgentUsageDTO{ +func agentUsageToDTOs(items []*models.AgentUsage) []dto.AgentUsageDTO { + result := make([]dto.AgentUsageDTO, 0, len(items)) + for _, au := range items { + result = append(result, dto.AgentUsageDTO{ AgentProfileID: au.AgentProfileID, AgentProfileName: au.AgentProfileName, AgentModel: au.AgentModel, @@ -138,18 +201,13 @@ func (h *StatsHandlers) httpGetStats(c *gin.Context) { TotalDurationMs: au.TotalDurationMs, }) } + return result +} - // Get repository stats - repoStats, err := h.repo.GetRepositoryStats(c.Request.Context(), workspaceID, start) - if err != nil { - h.logger.Error("failed to get repository stats", zap.String("workspace_id", workspaceID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) - return - } - - repoStatsDTOs := make([]dto.RepositoryStatsDTO, 0, len(repoStats)) - for _, rs := range repoStats { - repoStatsDTOs = append(repoStatsDTOs, dto.RepositoryStatsDTO{ +func repositoryStatsToDTOs(items []*models.RepositoryStats) []dto.RepositoryStatsDTO { + result := make([]dto.RepositoryStatsDTO, 0, len(items)) + for _, rs := range items { + result = append(result, dto.RepositoryStatsDTO{ RepositoryID: rs.RepositoryID, RepositoryName: rs.RepositoryName, TotalTasks: rs.TotalTasks, @@ -167,44 +225,7 @@ func (h *StatsHandlers) httpGetStats(c *gin.Context) { TotalDeletions: rs.TotalDeletions, }) } - - // Get git stats - gitStats, err := h.repo.GetGitStats(c.Request.Context(), workspaceID, start) - if err != nil { - h.logger.Error("failed to get git stats", zap.String("workspace_id", workspaceID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) - return - } - - response := dto.StatsResponse{ - Global: dto.GlobalStatsDTO{ - TotalTasks: globalStats.TotalTasks, - CompletedTasks: globalStats.CompletedTasks, - InProgressTasks: globalStats.InProgressTasks, - TotalSessions: globalStats.TotalSessions, - TotalTurns: globalStats.TotalTurns, - TotalMessages: globalStats.TotalMessages, - TotalUserMessages: globalStats.TotalUserMessages, - TotalToolCalls: globalStats.TotalToolCalls, - TotalDurationMs: globalStats.TotalDurationMs, - AvgTurnsPerTask: globalStats.AvgTurnsPerTask, - AvgMessagesPerTask: globalStats.AvgMessagesPerTask, - AvgDurationMsPerTask: globalStats.AvgDurationMsPerTask, - }, - TaskStats: taskStatsDTOs, - DailyActivity: dailyActivityDTOs, - CompletedActivity: completedActivityDTOs, - AgentUsage: agentUsageDTOs, - RepositoryStats: repoStatsDTOs, - GitStats: dto.GitStatsDTO{ - TotalCommits: gitStats.TotalCommits, - TotalFilesChanged: gitStats.TotalFilesChanged, - TotalInsertions: gitStats.TotalInsertions, - TotalDeletions: gitStats.TotalDeletions, - }, - } - - c.JSON(http.StatusOK, response) + return result } func parseStatsRange(rangeKey string) (*time.Time, int) { diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 5ffb8a8a78..0908dc5fa2 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -310,10 +310,42 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, startArg = start.UTC().Format(time.RFC3339) } - drv := r.db.DriverName() - dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") + query := buildRepositoryStatsQuery(r.db.DriverName()) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), + startArg, startArg, startArg, startArg, + startArg, startArg, startArg, startArg, + workspaceID, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() - query := fmt.Sprintf(` + var results []*models.RepositoryStats + for rows.Next() { + var stats models.RepositoryStats + var totalDurationMs float64 + err := rows.Scan( + &stats.RepositoryID, &stats.RepositoryName, + &stats.TotalTasks, &stats.CompletedTasks, &stats.InProgressTasks, + &stats.SessionCount, &stats.TurnCount, &stats.MessageCount, + &stats.UserMessageCount, &stats.ToolCallCount, &totalDurationMs, + &stats.TotalCommits, &stats.TotalFilesChanged, + &stats.TotalInsertions, &stats.TotalDeletions, + ) + if err != nil { + return nil, err + } + stats.TotalDurationMs = int64(totalDurationMs) + results = append(results, &stats) + } + + return results, rows.Err() +} + +func buildRepositoryStatsQuery(drv string) string { + dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") + return fmt.Sprintf(` SELECT r.id, r.name, COALESCE(task_stats.total_tasks, 0) as total_tasks, @@ -378,37 +410,6 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, WHERE r.workspace_id = ? AND r.deleted_at IS NULL ORDER BY total_duration_ms DESC, total_tasks DESC, r.name ASC `, dur) - - rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), - startArg, startArg, startArg, startArg, - startArg, startArg, startArg, startArg, - workspaceID, - ) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var results []*models.RepositoryStats - for rows.Next() { - var stats models.RepositoryStats - var totalDurationMs float64 - err := rows.Scan( - &stats.RepositoryID, &stats.RepositoryName, - &stats.TotalTasks, &stats.CompletedTasks, &stats.InProgressTasks, - &stats.SessionCount, &stats.TurnCount, &stats.MessageCount, - &stats.UserMessageCount, &stats.ToolCallCount, &totalDurationMs, - &stats.TotalCommits, &stats.TotalFilesChanged, - &stats.TotalInsertions, &stats.TotalDeletions, - ) - if err != nil { - return nil, err - } - stats.TotalDurationMs = int64(totalDurationMs) - results = append(results, &stats) - } - - return results, rows.Err() } // GetAgentUsage retrieves usage statistics per agent profile diff --git a/apps/backend/internal/clarification/handlers.go b/apps/backend/internal/clarification/handlers.go index 2532427244..a49060d8c1 100644 --- a/apps/backend/internal/clarification/handlers.go +++ b/apps/backend/internal/clarification/handlers.go @@ -177,43 +177,9 @@ func (h *Handlers) httpRespond(c *gin.Context) { pending, ok := h.store.GetRequest(pendingID) // If request not found in store (likely timed out), mark the message as expired - // so the UI stops showing it as pending var sessionID string if !ok { - h.logger.Warn("clarification request not found in store (likely timed out), marking message as expired", - zap.String("pending_id", pendingID)) - - // Find the message by pending_id and mark it as expired - message, err := h.repo.FindMessageByPendingID(c.Request.Context(), pendingID) - if err != nil { - h.logger.Warn("failed to find clarification message for expired request", - zap.String("pending_id", pendingID), - zap.Error(err)) - c.JSON(http.StatusGone, gin.H{ - "error": "clarification request expired", - "code": "CLARIFICATION_EXPIRED", - "message": "This question has timed out. The agent may have already moved on. Please refresh the page.", - }) - return - } - - // Update the message status to expired - if message.Metadata == nil { - message.Metadata = make(map[string]interface{}) - } - message.Metadata["status"] = "expired" - if err := h.repo.UpdateMessage(c.Request.Context(), message); err != nil { - h.logger.Warn("failed to update expired clarification message", - zap.String("pending_id", pendingID), - zap.String("message_id", message.ID), - zap.Error(err)) - } - - c.JSON(http.StatusGone, gin.H{ - "error": "clarification request expired", - "code": "CLARIFICATION_EXPIRED", - "message": "This question has timed out. The agent may have already moved on. The page will refresh automatically.", - }) + h.markRequestExpired(c, pendingID) return } @@ -258,6 +224,40 @@ func (h *Handlers) httpRespond(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true}) } +// markRequestExpired handles the case where a clarification request has expired (not found in store). +// It attempts to find and update the message in the database to show it as expired. +func (h *Handlers) markRequestExpired(c *gin.Context, pendingID string) { + h.logger.Warn("clarification request not found in store (likely timed out), marking message as expired", + zap.String("pending_id", pendingID)) + message, err := h.repo.FindMessageByPendingID(c.Request.Context(), pendingID) + if err != nil { + h.logger.Warn("failed to find clarification message for expired request", + zap.String("pending_id", pendingID), + zap.Error(err)) + c.JSON(http.StatusGone, gin.H{ + "error": "clarification request expired", + "code": "CLARIFICATION_EXPIRED", + "message": "This question has timed out. The agent may have already moved on. Please refresh the page.", + }) + return + } + if message.Metadata == nil { + message.Metadata = make(map[string]interface{}) + } + message.Metadata["status"] = "expired" + if err := h.repo.UpdateMessage(c.Request.Context(), message); err != nil { + h.logger.Warn("failed to update expired clarification message", + zap.String("pending_id", pendingID), + zap.String("message_id", message.ID), + zap.Error(err)) + } + c.JSON(http.StatusGone, gin.H{ + "error": "clarification request expired", + "code": "CLARIFICATION_EXPIRED", + "message": "This question has timed out. The agent may have already moved on. The page will refresh automatically.", + }) +} + func generateOptionID(questionIndex, optionIndex int) string { return fmt.Sprintf("q%d_opt%d", questionIndex+1, optionIndex+1) } diff --git a/apps/backend/internal/debug/fixture.go b/apps/backend/internal/debug/fixture.go index b9b2ae17b1..2f5d4cc969 100644 --- a/apps/backend/internal/debug/fixture.go +++ b/apps/backend/internal/debug/fixture.go @@ -15,6 +15,13 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +// Protocol and file type constants. +const ( + protocolACP = "acp" + protocolStreamJSON = "streamjson" + filetypeUnknown = "unknown" +) + // DiscoveredFile represents a discovered fixture file. type DiscoveredFile struct { Path string `json:"path"` @@ -324,10 +331,10 @@ func parseDebugFilename(filename string) (fileType, protocol, agent string) { // Legacy pattern: {protocol}-{agent} protocol, agent = splitProtocolAgent(name) if isKnownProtocol(protocol) { - return "unknown", protocol, agent + return filetypeUnknown, protocol, agent } - return "unknown", "unknown", "" + return filetypeUnknown, filetypeUnknown, "" } // splitProtocolAgent splits "{protocol}-{agent}" into protocol and agent. @@ -348,7 +355,7 @@ func parseProtocolFromFilename(filename string) string { // isKnownProtocol checks if the protocol is one of the known protocols. func isKnownProtocol(protocol string) bool { switch protocol { - case "acp", "streamjson", "codex", "opencode": + case protocolACP, protocolStreamJSON, "codex", "opencode": return true default: return false @@ -375,9 +382,9 @@ func countLines(path string) int { // getNormalizerForProtocol returns the appropriate normalizer for the protocol. func getNormalizerForProtocol(protocol string) normalizer { switch protocol { - case "acp": + case protocolACP: return acp.NewNormalizer() - case "streamjson": + case protocolStreamJSON: return streamjson.NewNormalizer() default: return nil @@ -415,14 +422,14 @@ func normalizeFixtureFile(filePath string) ([]NormalizedFixture, error) { // Extract tool info and normalize based on protocol switch protocol { - case "acp": + case protocolACP: args, _ := input.Input["args"].(map[string]any) kind, _ := args["kind"].(string) fixture.ToolName = kind fixture.ToolType = acp.DetectToolOperationType(kind, args) fixture.Payload = norm.NormalizeToolCall(kind, args) - case "streamjson": + case protocolStreamJSON: toolName, _ := input.Input["tool_name"].(string) args, _ := input.Input["args"].(map[string]any) fixture.ToolName = toolName diff --git a/apps/backend/internal/editors/service/service.go b/apps/backend/internal/editors/service/service.go index f58431df0b..38f08db7c3 100644 --- a/apps/backend/internal/editors/service/service.go +++ b/apps/backend/internal/editors/service/service.go @@ -28,6 +28,13 @@ var ( ErrWorkspaceNotFound = errors.New("workspace path not found") ) +// Editor kind constants for custom editor types. +const ( + editorKindCustomRemoteSSH = "custom_remote_ssh" + editorKindCustomHostedURL = "custom_hosted_url" + editorKindCustomCommand = "custom_command" +) + type UserSettingsProvider interface { GetUserSettings(ctx context.Context) (*usermodels.UserSettings, error) ClearDefaultEditorID(ctx context.Context, editorID string) error @@ -91,45 +98,57 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string return "", err } + return s.dispatchEditorKind(editor, worktreePath, absPath, input.Line, input.Column) +} + +func (s *Service) dispatchEditorKind(editor *models.Editor, worktreePath, absPath string, line, column int) (string, error) { switch editor.Kind { - case "custom_remote_ssh": - cfg, err := parseRemoteSSHConfig(editor.Config) - if err != nil { - return "", err - } - scheme := cfg.Scheme - if scheme == "" { - scheme = editor.Scheme - } - return buildRemoteSSHURL(scheme, cfg.User, cfg.Host, absPath, input.Line, input.Column), nil - case "custom_hosted_url": + case editorKindCustomRemoteSSH: + return openRemoteSSHEditor(editor, absPath, line, column) + case editorKindCustomHostedURL: cfg, err := parseHostedURLConfig(editor.Config) if err != nil { return "", err } - return buildHostedURL(cfg.URL, absPath, input.Line, input.Column) - case "custom_command": + return buildHostedURL(cfg.URL, absPath, line, column) + case editorKindCustomCommand: cfg, err := parseCommandConfig(editor.Config) if err != nil { return "", err } - return launchCommand(cfg.Command, worktreePath, absPath, input.Line, input.Column) + return launchCommand(cfg.Command, worktreePath, absPath, line, column) default: - if !editor.Installed { - return "", ErrEditorUnavailable - } - if editor.Command == "" { - return "", ErrEditorConfigInvalid - } - args := buildLocalArgs(editor.Type, absPath, input.Line, input.Column) - cmd := exec.Command(editor.Command, args...) - if err := cmd.Start(); err != nil { - return "", fmt.Errorf("launch failed: %w", err) - } - return "", nil + return openBuiltinEditor(editor, absPath, line, column) } } +func openRemoteSSHEditor(editor *models.Editor, absPath string, line, column int) (string, error) { + cfg, err := parseRemoteSSHConfig(editor.Config) + if err != nil { + return "", err + } + scheme := cfg.Scheme + if scheme == "" { + scheme = editor.Scheme + } + return buildRemoteSSHURL(scheme, cfg.User, cfg.Host, absPath, line, column), nil +} + +func openBuiltinEditor(editor *models.Editor, absPath string, line, column int) (string, error) { + if !editor.Installed { + return "", ErrEditorUnavailable + } + if editor.Command == "" { + return "", ErrEditorConfigInvalid + } + args := buildLocalArgs(editor.Type, absPath, line, column) + cmd := exec.Command(editor.Command, args...) + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("launch failed: %w", err) + } + return "", nil +} + func (s *Service) OpenFolder(ctx context.Context, sessionID string) error { if sessionID == "" { return ErrEditorConfigInvalid @@ -488,7 +507,7 @@ func expandCommandPlaceholders(template, worktreePath, absPath string, line, col func isCustomKind(kind string) bool { switch kind { - case "custom_command", "custom_remote_ssh", "custom_hosted_url": + case editorKindCustomCommand, editorKindCustomRemoteSSH, editorKindCustomHostedURL: return true default: return false @@ -500,13 +519,13 @@ func validateEditorConfig(editor *models.Editor) error { return ErrEditorConfigInvalid } switch editor.Kind { - case "custom_command": + case editorKindCustomCommand: _, err := parseCommandConfig(editor.Config) return err - case "custom_remote_ssh": + case editorKindCustomRemoteSSH: _, err := parseRemoteSSHConfig(editor.Config) return err - case "custom_hosted_url": + case editorKindCustomHostedURL: _, err := parseHostedURLConfig(editor.Config) return err default: diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index d953013b13..df3697790a 100644 --- a/apps/backend/internal/gateway/websocket/hub.go +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -102,39 +102,41 @@ func (h *Hub) removeClient(client *Client) { h.mu.Lock() defer h.mu.Unlock() - if _, ok := h.clients[client]; ok { - delete(h.clients, client) - client.closeSend() - - // Remove from all task subscriptions - for taskID := range client.subscriptions { - if clients, ok := h.taskSubscribers[taskID]; ok { - delete(clients, client) - if len(clients) == 0 { - delete(h.taskSubscribers, taskID) - } - } - } - for sessionID := range client.sessionSubscriptions { - if clients, ok := h.sessionSubscribers[sessionID]; ok { - delete(clients, client) - if len(clients) == 0 { - delete(h.sessionSubscribers, sessionID) - } - } - } - for userID := range client.userSubscriptions { - if clients, ok := h.userSubscribers[userID]; ok { - delete(clients, client) - if len(clients) == 0 { - delete(h.userSubscribers, userID) - } - } - } + if _, ok := h.clients[client]; !ok { + h.logger.Debug("Client unregistered", zap.String("client_id", client.ID)) + return } + + delete(h.clients, client) + client.closeSend() + + // Remove from all task subscriptions + for taskID := range client.subscriptions { + removeClientFromSubscriberMap(h.taskSubscribers, taskID, client) + } + for sessionID := range client.sessionSubscriptions { + removeClientFromSubscriberMap(h.sessionSubscribers, sessionID, client) + } + for userID := range client.userSubscriptions { + removeClientFromSubscriberMap(h.userSubscribers, userID, client) + } + h.logger.Debug("Client unregistered", zap.String("client_id", client.ID)) } +// removeClientFromSubscriberMap removes a client from a subscriber map entry, +// deleting the entry entirely when no subscribers remain. +func removeClientFromSubscriberMap(subscribers map[string]map[*Client]bool, key string, client *Client) { + clients, ok := subscribers[key] + if !ok { + return + } + delete(clients, client) + if len(clients) == 0 { + delete(subscribers, key) + } +} + // broadcastMessage sends a message to relevant clients func (h *Hub) broadcastMessage(msg *ws.Message) { data, err := json.Marshal(msg) @@ -168,6 +170,33 @@ func (h *Hub) Broadcast(msg *ws.Message) { h.broadcast <- msg } +// getSubscribersLocked reads subscribers for an ID from a subscriber map under the read lock. +func (h *Hub) getSubscribersLocked(m map[string]map[*Client]bool, id string) []*Client { + h.mu.RLock() + subscriberMap := m[id] + clients := make([]*Client, 0, len(subscriberMap)) + for client := range subscriberMap { + clients = append(clients, client) + } + h.mu.RUnlock() + return clients +} + +// sendToClients delivers a pre-marshalled message to a list of clients. +func (h *Hub) sendToClients(data []byte, clients []*Client, action string) { + for _, client := range clients { + if client.sendBytes(data) { + h.logger.Debug("Sent message to client", + zap.String("client_id", client.ID), + zap.String("action", action)) + } else { + h.logger.Warn("Client send buffer full, dropping message", + zap.String("client_id", client.ID), + zap.String("action", action)) + } + } +} + // BroadcastToTask sends a notification to clients subscribed to a specific task func (h *Hub) BroadcastToTask(taskID string, msg *ws.Message) { data, err := json.Marshal(msg) @@ -175,32 +204,12 @@ func (h *Hub) BroadcastToTask(taskID string, msg *ws.Message) { h.logger.Error("Failed to marshal message", zap.Error(err)) return } - - // Copy client list while holding the lock to avoid race conditions - h.mu.RLock() - subscriberMap := h.taskSubscribers[taskID] - clients := make([]*Client, 0, len(subscriberMap)) - for client := range subscriberMap { - clients = append(clients, client) - } - h.mu.RUnlock() - + clients := h.getSubscribersLocked(h.taskSubscribers, taskID) h.logger.Debug("BroadcastToTask", zap.String("task_id", taskID), zap.String("action", msg.Action), zap.Int("subscriber_count", len(clients))) - - for _, client := range clients { - if client.sendBytes(data) { - h.logger.Debug("Sent message to client", - zap.String("client_id", client.ID), - zap.String("action", msg.Action)) - } else { - h.logger.Warn("Client send buffer full, dropping message", - zap.String("client_id", client.ID), - zap.String("action", msg.Action)) - } - } + h.sendToClients(data, clients, msg.Action) } // BroadcastToSession sends a notification to clients subscribed to a specific session @@ -210,31 +219,12 @@ func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { h.logger.Error("Failed to marshal message", zap.Error(err)) return } - - h.mu.RLock() - subscriberMap := h.sessionSubscribers[sessionID] - clients := make([]*Client, 0, len(subscriberMap)) - for client := range subscriberMap { - clients = append(clients, client) - } - h.mu.RUnlock() - + clients := h.getSubscribersLocked(h.sessionSubscribers, sessionID) h.logger.Debug("BroadcastToSession", zap.String("session_id", sessionID), zap.String("action", msg.Action), zap.Int("subscriber_count", len(clients))) - - for _, client := range clients { - if client.sendBytes(data) { - h.logger.Debug("Sent message to client", - zap.String("client_id", client.ID), - zap.String("action", msg.Action)) - } else { - h.logger.Warn("Client send buffer full, dropping message", - zap.String("client_id", client.ID), - zap.String("action", msg.Action)) - } - } + h.sendToClients(data, clients, msg.Action) } // BroadcastToUser sends a notification to clients subscribed to a specific user @@ -244,32 +234,12 @@ func (h *Hub) BroadcastToUser(userID string, msg *ws.Message) { h.logger.Error("Failed to marshal message", zap.Error(err)) return } - - // Copy client list while holding the lock to avoid race conditions - h.mu.RLock() - subscriberMap := h.userSubscribers[userID] - clients := make([]*Client, 0, len(subscriberMap)) - for client := range subscriberMap { - clients = append(clients, client) - } - h.mu.RUnlock() - + clients := h.getSubscribersLocked(h.userSubscribers, userID) h.logger.Debug("BroadcastToUser", zap.String("user_id", userID), zap.String("action", msg.Action), zap.Int("subscriber_count", len(clients))) - - for _, client := range clients { - if client.sendBytes(data) { - h.logger.Debug("Sent message to client", - zap.String("client_id", client.ID), - zap.String("action", msg.Action)) - } else { - h.logger.Warn("Client send buffer full, dropping message", - zap.String("client_id", client.ID), - zap.String("action", msg.Action)) - } - } + h.sendToClients(data, clients, msg.Action) } // SubscribeToTask subscribes a client to task notifications diff --git a/apps/backend/internal/gateway/websocket/lsp_handler.go b/apps/backend/internal/gateway/websocket/lsp_handler.go index a6698d59d0..4a65521f3a 100644 --- a/apps/backend/internal/gateway/websocket/lsp_handler.go +++ b/apps/backend/internal/gateway/websocket/lsp_handler.go @@ -101,14 +101,7 @@ func (h *LSPHandler) HandleLSPConnection(c *gin.Context) { if !exists { h.logger.Warn("LSP: session not found in lifecycle manager", zap.String("session_id", sessionID)) - conn, err := lspUpgrader.Upgrade(c.Writer, c.Request, nil) - if err != nil { - h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(err)) - return - } - _ = conn.WriteMessage(gorillaws.CloseMessage, - gorillaws.FormatCloseMessage(lspCloseSessionNotFound, "session not found")) - _ = conn.Close() + h.closeWithCode(c, lspCloseSessionNotFound, "session not found") return } @@ -120,71 +113,7 @@ func (h *LSPHandler) HandleLSPConnection(c *gin.Context) { // Check if binary is available binaryPath, err := h.installer.BinaryPath(language) if err != nil { - h.logger.Info("LSP: binary not found", - zap.String("language", language), - zap.Error(err)) - // Binary not found — check auto-install setting - settings, settingsErr := h.userService.GetUserSettings(c.Request.Context()) - autoInstall := false - if settingsErr == nil && settings != nil { - for _, lang := range settings.LspAutoInstallLanguages { - if lang == language { - autoInstall = true - break - } - } - } - h.logger.Debug("LSP: auto-install check", - zap.String("language", language), - zap.Bool("auto_install", autoInstall), - zap.Error(settingsErr)) - - if !autoInstall { - h.logger.Info("LSP: closing with binary not found (auto-install off)", - zap.String("language", language), - zap.String("error", err.Error())) - conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil) - if upgradeErr != nil { - h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(upgradeErr)) - return - } - _ = conn.WriteMessage(gorillaws.CloseMessage, - gorillaws.FormatCloseMessage(lspCloseBinaryNotFound, err.Error())) - _ = conn.Close() - return - } - - // Auto-install: upgrade WS, send status messages, install - conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil) - if upgradeErr != nil { - return - } - - if err := writeJSONMessage(conn, map[string]string{"status": "installing", "language": language}); err != nil { - h.logger.Warn("failed to send status message", zap.Error(err)) - } - - // Concurrency protection: wait if another install for the same language is in progress - binaryPath, err = h.awaitOrInstall(c.Request.Context(), language) - if err != nil { - h.logger.Error("LSP auto-install failed", - zap.String("language", language), - zap.Error(err)) - if err := writeJSONMessage(conn, map[string]string{"status": "install_failed", "error": err.Error()}); err != nil { - h.logger.Warn("failed to send status message", zap.Error(err)) - } - _ = conn.WriteMessage(gorillaws.CloseMessage, - gorillaws.FormatCloseMessage(lspCloseInstallFailed, "install failed")) - _ = conn.Close() - return - } - - if err := writeJSONMessage(conn, map[string]string{"status": "installed"}); err != nil { - h.logger.Warn("failed to send status message", zap.Error(err)) - } - - // Continue with the already-upgraded connection - h.handleLSPBridge(conn, sessionID, language, binaryPath, workspacePath) + h.handleBinaryNotFound(c, sessionID, language, workspacePath, err) return } @@ -203,6 +132,88 @@ func (h *LSPHandler) HandleLSPConnection(c *gin.Context) { h.handleLSPBridge(conn, sessionID, language, binaryPath, workspacePath) } +// closeWithCode upgrades the WebSocket and immediately closes it with the given code. +func (h *LSPHandler) closeWithCode(c *gin.Context, code int, text string) { + conn, err := lspUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(err)) + return + } + _ = conn.WriteMessage(gorillaws.CloseMessage, gorillaws.FormatCloseMessage(code, text)) + _ = conn.Close() +} + +// handleBinaryNotFound is called when the LSP binary is not installed. +// It checks the auto-install setting and either closes the connection or installs then bridges. +func (h *LSPHandler) handleBinaryNotFound(c *gin.Context, sessionID, language, workspacePath string, binaryErr error) { + h.logger.Info("LSP: binary not found", + zap.String("language", language), + zap.Error(binaryErr)) + + // Check auto-install setting + settings, settingsErr := h.userService.GetUserSettings(c.Request.Context()) + autoInstall := false + if settingsErr == nil && settings != nil { + for _, lang := range settings.LspAutoInstallLanguages { + if lang == language { + autoInstall = true + break + } + } + } + h.logger.Debug("LSP: auto-install check", + zap.String("language", language), + zap.Bool("auto_install", autoInstall), + zap.Error(settingsErr)) + + if !autoInstall { + h.logger.Info("LSP: closing with binary not found (auto-install off)", + zap.String("language", language), + zap.String("error", binaryErr.Error())) + conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil) + if upgradeErr != nil { + h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(upgradeErr)) + return + } + _ = conn.WriteMessage(gorillaws.CloseMessage, + gorillaws.FormatCloseMessage(lspCloseBinaryNotFound, binaryErr.Error())) + _ = conn.Close() + return + } + + // Auto-install: upgrade WS, send status messages, install, then bridge + conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil) + if upgradeErr != nil { + return + } + + if err := writeJSONMessage(conn, map[string]string{"status": "installing", "language": language}); err != nil { + h.logger.Warn("failed to send status message", zap.Error(err)) + } + + // Concurrency protection: wait if another install for the same language is in progress + binaryPath, err := h.awaitOrInstall(c.Request.Context(), language) + if err != nil { + h.logger.Error("LSP auto-install failed", + zap.String("language", language), + zap.Error(err)) + if writeErr := writeJSONMessage(conn, map[string]string{"status": "install_failed", "error": err.Error()}); writeErr != nil { + h.logger.Warn("failed to send status message", zap.Error(writeErr)) + } + _ = conn.WriteMessage(gorillaws.CloseMessage, + gorillaws.FormatCloseMessage(lspCloseInstallFailed, "install failed")) + _ = conn.Close() + return + } + + if err := writeJSONMessage(conn, map[string]string{"status": "installed"}); err != nil { + h.logger.Warn("failed to send status message", zap.Error(err)) + } + + // Continue with the already-upgraded connection + h.handleLSPBridge(conn, sessionID, language, binaryPath, workspacePath) +} + // awaitOrInstall either waits for an in-progress installation to finish or // performs the installation itself, broadcasting completion to other waiters. func (h *LSPHandler) awaitOrInstall(ctx context.Context, language string) (string, error) { diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index c8a44e8a24..fe435a2658 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -287,81 +287,142 @@ func (h *TerminalHandler) runTerminalBridge( continue } - // Check for resize command (first byte 0x01) if data[0] == resizeCommandByte { - // Set up PTY access BEFORE handling resize if not already done. - // This ensures buffered output is sent to the terminal before the resize - // triggers a TUI redraw (which would overwrite the scrollback). - if !directOutputSet { - if h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, &ptyWriter) { - directOutputSet = true - } - } - - // Handle resize — may return updated processID if process was restarted. - // Falls back to session-level resize when the old process is gone. - newProcessID := h.handleResize(data[1:], sessionID, processID, interactiveRunner) - if newProcessID != processID { - processID = newProcessID - directOutputSet = false - ptyWriter = nil - } - - // Retry setupPtyAccess after resize — process may have been lazily started - // by the resize (deferred-start), so the PTY is now available. - if !directOutputSet { - if h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, &ptyWriter) { - directOutputSet = true - } - } + newID, newDirect := h.handleResizeCommand(data[1:], sessionID, processID, interactiveRunner, wsw, directOutputSet, &ptyWriter) + processID = newID + directOutputSet = newDirect continue } - // Regular input - write to PTY if available - if ptyWriter != nil { - if _, err := ptyWriter.Write(data); err != nil { - h.logger.Debug("PTY write error", - zap.String("session_id", sessionID), - zap.String("process_id", processID), - zap.Error(err)) + newID, newDirect := h.handleTerminalInput(data, sessionID, processID, interactiveRunner, wsw, &ptyWriter, directOutputSet) + processID = newID + directOutputSet = newDirect + } +} - // Reset state and discover the new process immediately. - // Without this, the bridge would be stuck with the old processID - // until a resize message arrives (which may never come after restart). - ptyWriter = nil - directOutputSet = false - processID = h.discoverProcessBySession(sessionID, processID, interactiveRunner) - continue - } - // Detect Enter key (user submitted input) - notify lifecycle manager - h.detectInputSubmission(sessionID, data) - } else { - // PTY not ready — try setup. If it fails, processID may be stale - // (process was restarted with a new ID). Discover the current process - // by session before giving up. - if !h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, &ptyWriter) { - newID := h.discoverProcessBySession(sessionID, processID, interactiveRunner) - if newID != processID { - processID = newID - h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, &ptyWriter) - } - } - if ptyWriter == nil { - h.logger.Debug("PTY not ready, dropping input", - zap.String("session_id", sessionID), - zap.Int("bytes", len(data))) - continue - } +// handleResizeCommand processes a resize message in the terminal bridge loop. +// Returns the (possibly updated) processID and directOutputSet flag. +func (h *TerminalHandler) handleResizeCommand( + payload []byte, + sessionID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + directOutputSet bool, + ptyWriter *io.Writer, +) (string, bool) { + // Set up PTY access BEFORE handling resize if not already done. + // This ensures buffered output is sent to the terminal before the resize + // triggers a TUI redraw (which would overwrite the scrollback). + if !directOutputSet { + if h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, ptyWriter) { directOutputSet = true - if _, err := ptyWriter.Write(data); err != nil { - h.logger.Debug("PTY write error after setup", - zap.String("session_id", sessionID), - zap.Error(err)) - continue - } - h.detectInputSubmission(sessionID, data) } } + + // Handle resize — may return updated processID if process was restarted. + // Falls back to session-level resize when the old process is gone. + newProcessID := h.handleResize(payload, sessionID, processID, interactiveRunner) + if newProcessID != processID { + processID = newProcessID + directOutputSet = false + *ptyWriter = nil + } + + // Retry setupPtyAccess after resize — process may have been lazily started + // by the resize (deferred-start), so the PTY is now available. + if !directOutputSet { + if h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, ptyWriter) { + directOutputSet = true + } + } + + return processID, directOutputSet +} + +// handleTerminalInput processes regular (non-resize) input in the terminal bridge loop. +// Returns the (possibly updated) processID and directOutputSet flag. +func (h *TerminalHandler) handleTerminalInput( + data []byte, + sessionID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + ptyWriter *io.Writer, + directOutputSet bool, +) (string, bool) { + if *ptyWriter != nil { + return h.writeToReadyPty(data, sessionID, processID, interactiveRunner, wsw, ptyWriter, directOutputSet) + } + return h.writeToUnreadyPty(data, sessionID, processID, interactiveRunner, wsw, ptyWriter, directOutputSet) +} + +// writeToReadyPty writes input to an already-established PTY writer. +// If writing fails it resets state and discovers the new process. +func (h *TerminalHandler) writeToReadyPty( + data []byte, + sessionID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + ptyWriter *io.Writer, + directOutputSet bool, +) (string, bool) { + if _, err := (*ptyWriter).Write(data); err != nil { + h.logger.Debug("PTY write error", + zap.String("session_id", sessionID), + zap.String("process_id", processID), + zap.Error(err)) + + // Reset state and discover the new process immediately. + // Without this, the bridge would be stuck with the old processID + // until a resize message arrives (which may never come after restart). + *ptyWriter = nil + directOutputSet = false + processID = h.discoverProcessBySession(sessionID, processID, interactiveRunner) + return processID, directOutputSet + } + // Detect Enter key (user submitted input) - notify lifecycle manager + h.detectInputSubmission(sessionID, data) + return processID, directOutputSet +} + +// writeToUnreadyPty attempts to set up PTY access and then write input. +// If setup fails it tries to discover the current process by session. +func (h *TerminalHandler) writeToUnreadyPty( + data []byte, + sessionID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + ptyWriter *io.Writer, + directOutputSet bool, +) (string, bool) { + // PTY not ready — try setup. If it fails, processID may be stale + // (process was restarted with a new ID). Discover the current process + // by session before giving up. + if !h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, ptyWriter) { + newID := h.discoverProcessBySession(sessionID, processID, interactiveRunner) + if newID != processID { + processID = newID + h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, ptyWriter) + } + } + if *ptyWriter == nil { + h.logger.Debug("PTY not ready, dropping input", + zap.String("session_id", sessionID), + zap.Int("bytes", len(data))) + return processID, directOutputSet + } + directOutputSet = true + if _, err := (*ptyWriter).Write(data); err != nil { + h.logger.Debug("PTY write error after setup", + zap.String("session_id", sessionID), + zap.Error(err)) + return processID, directOutputSet + } + h.detectInputSubmission(sessionID, data) + return processID, directOutputSet } // detectInputSubmission checks if the input contains Enter key and notifies @@ -504,78 +565,98 @@ func (h *TerminalHandler) resizeBySessionFallback( return oldProcessID } -// handleUserShellWS handles WebSocket connections for user shell terminals. -// Each terminal tab gets its own independent shell process. -func (h *TerminalHandler) handleUserShellWS( +// shellScriptInfo holds the label and initial command resolved from a script. +type shellScriptInfo struct { + label string + initialCommand string +} + +// resolveShellScript looks up a script by ID and returns its label and command. +// Returns an error if the script cannot be retrieved. +func (h *TerminalHandler) resolveShellScript(ctx context.Context, scriptID string) (*shellScriptInfo, error) { + script, err := h.scriptService.GetRepositoryScript(ctx, scriptID) + if err != nil { + h.logger.Error("failed to get repository script", + zap.String("script_id", scriptID), + zap.Error(err)) + return nil, err + } + h.logger.Info("handleUserShellWS: resolved script", + zap.String("script_id", scriptID), + zap.String("script_name", script.Name), + zap.String("script_command", script.Command)) + return &shellScriptInfo{label: script.Name, initialCommand: script.Command}, nil +} + +// resolvePreferredShell returns the user's preferred shell, or empty string on error. +func (h *TerminalHandler) resolvePreferredShell(ctx context.Context) string { + if h.userService == nil { + return "" + } + shell, err := h.userService.PreferredShell(ctx) + if err != nil { + h.logger.Debug("failed to get preferred shell, using default", zap.Error(err)) + return "" + } + return shell +} + +// resolveWorkingDir returns the workspace path for the given session, if available. +func (h *TerminalHandler) resolveWorkingDir(sessionID string) string { + execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID) + if !exists { + h.logger.Info("handleUserShellWS: no execution found, using empty working directory") + return "" + } + h.logger.Info("handleUserShellWS: got working directory from execution", + zap.String("working_dir", execution.WorkspacePath)) + return execution.WorkspacePath +} + +// resolveShellLabel returns the label and initial command for a user shell, derived +// from either a script ID lookup or a plain label query parameter. +// Returns an HTTP error string (non-empty) when the script lookup fails. +func (h *TerminalHandler) resolveShellLabel(c *gin.Context) (label, initialCommand, httpErr string) { + scriptID := c.Query("scriptId") + labelParam := c.Query("label") + + if scriptID != "" && h.scriptService != nil { + info, err := h.resolveShellScript(c.Request.Context(), scriptID) + if err != nil { + return "", "", "invalid script ID" + } + return info.label, info.initialCommand, "" + } + if labelParam != "" { + return labelParam, "", "" + } + return "", "", "" +} + +// startUserShellProcess resolves shell parameters and starts (or reconnects to) the +// user shell process. Returns the process ID on success, or an HTTP status + message on failure. +func (h *TerminalHandler) startUserShellProcess( c *gin.Context, sessionID string, terminalID string, interactiveRunner *process.InteractiveRunner, -) { - // Get optional parameters from query - scriptID := c.Query("scriptId") // Repository script ID to run - labelParam := c.Query("label") // Label for plain shell terminals - - var label string - var initialCommand string - - // If scriptId is provided, look up the script from the database - if scriptID != "" && h.scriptService != nil { - script, err := h.scriptService.GetRepositoryScript(c.Request.Context(), scriptID) - if err != nil { - h.logger.Error("failed to get repository script", - zap.String("script_id", scriptID), - zap.Error(err)) - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid script ID"}) - return - } - label = script.Name - initialCommand = script.Command - h.logger.Info("handleUserShellWS: resolved script", - zap.String("script_id", scriptID), - zap.String("script_name", script.Name), - zap.String("script_command", script.Command)) - } else if labelParam != "" { - // Use label from query param for plain shell terminals - label = labelParam +) (processID string, httpStatus int, errMsg string) { + label, initialCommand, httpErr := h.resolveShellLabel(c) + if httpErr != "" { + return "", http.StatusBadRequest, httpErr } h.logger.Info("handleUserShellWS: starting user shell handling", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), - zap.String("script_id", scriptID), zap.String("label", label), zap.String("initial_command", initialCommand)) - // Get preferred shell from user settings - var preferredShell string - if h.userService != nil { - shell, err := h.userService.PreferredShell(c.Request.Context()) - if err != nil { - h.logger.Debug("failed to get preferred shell, using default", - zap.Error(err)) - } else { - preferredShell = shell - } - } + preferredShell := h.resolvePreferredShell(c.Request.Context()) + workingDir := h.resolveWorkingDir(sessionID) - // Get working directory from execution (workspace path) - workingDir := "" - if execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID); exists { - workingDir = execution.WorkspacePath - h.logger.Info("handleUserShellWS: got working directory from execution", - zap.String("working_dir", workingDir)) - } else { - h.logger.Info("handleUserShellWS: no execution found, using empty working directory") - } + opts := &process.UserShellOptions{Label: label, InitialCommand: initialCommand} - // Build shell options - opts := &process.UserShellOptions{ - Label: label, - InitialCommand: initialCommand, - } - - // Start or get existing user shell for this terminal h.logger.Info("handleUserShellWS: calling StartUserShell", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), @@ -585,29 +666,38 @@ func (h *TerminalHandler) handleUserShellWS( zap.String("initial_command", opts.InitialCommand)) info, err := interactiveRunner.StartUserShell( - c.Request.Context(), - sessionID, - terminalID, - workingDir, - preferredShell, - opts, + c.Request.Context(), sessionID, terminalID, workingDir, preferredShell, opts, ) if err != nil { h.logger.Error("failed to start user shell", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), zap.Error(err)) - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) - return + return "", http.StatusServiceUnavailable, err.Error() } - processID := info.ID h.logger.Info("handleUserShellWS: user shell started successfully", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), - zap.String("process_id", processID)) + zap.String("process_id", info.ID)) + + return info.ID, 0, "" +} + +// handleUserShellWS handles WebSocket connections for user shell terminals. +// Each terminal tab gets its own independent shell process. +func (h *TerminalHandler) handleUserShellWS( + c *gin.Context, + sessionID string, + terminalID string, + interactiveRunner *process.InteractiveRunner, +) { + processID, httpStatus, errMsg := h.startUserShellProcess(c, sessionID, terminalID, interactiveRunner) + if errMsg != "" { + c.JSON(httpStatus, gin.H{"error": errMsg}) + return + } - // Upgrade to WebSocket conn, err := terminalUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { h.logger.Error("failed to upgrade to WebSocket", @@ -622,10 +712,7 @@ func (h *TerminalHandler) handleUserShellWS( zap.String("terminal_id", terminalID), zap.String("process_id", processID)) - // Create WebSocket writer for output wsw := newWsWriter(conn) - - // Run the user shell terminal bridge h.runUserShellBridge(conn, sessionID, terminalID, processID, interactiveRunner, wsw) } @@ -676,58 +763,119 @@ func (h *TerminalHandler) runUserShellBridge( continue } - // Check for resize command (first byte 0x01) if data[0] == resizeCommandByte { - // Set up PTY access BEFORE handling resize if not already done - if !directOutputSet { - if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, &ptyWriter) { - directOutputSet = true - } - } - - // Handle resize for user shell - h.handleUserShellResize(data[1:], sessionID, terminalID, interactiveRunner) + directOutputSet = h.handleUserShellResizeCommand( + data[1:], sessionID, terminalID, processID, + interactiveRunner, wsw, directOutputSet, &ptyWriter, + ) continue } - // Regular input - write to PTY if available - if ptyWriter != nil { - if _, err := ptyWriter.Write(data); err != nil { - h.logger.Debug("PTY write error", + directOutputSet = h.handleUserShellInput( + data, sessionID, terminalID, processID, + interactiveRunner, wsw, &ptyWriter, directOutputSet, + ) + } +} + +// handleUserShellResizeCommand processes a resize message in the user shell bridge loop. +// Returns the updated directOutputSet flag. +func (h *TerminalHandler) handleUserShellResizeCommand( + payload []byte, + sessionID string, + terminalID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + directOutputSet bool, + ptyWriter *io.Writer, +) bool { + // Set up PTY access BEFORE handling resize if not already done + if !directOutputSet { + if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { + directOutputSet = true + } + } + h.handleUserShellResize(payload, sessionID, terminalID, interactiveRunner) + return directOutputSet +} + +// handleUserShellInput processes regular (non-resize) input in the user shell bridge loop. +// Returns the updated directOutputSet flag. +func (h *TerminalHandler) handleUserShellInput( + data []byte, + sessionID string, + terminalID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + ptyWriter *io.Writer, + directOutputSet bool, +) bool { + if *ptyWriter != nil { + return h.writeToReadyUserShellPty(data, sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter, directOutputSet) + } + return h.writeToUnreadyUserShellPty(data, sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter, directOutputSet) +} + +// writeToReadyUserShellPty writes input to an established user shell PTY. +// On failure it attempts to re-establish PTY access and retries the write. +func (h *TerminalHandler) writeToReadyUserShellPty( + data []byte, + sessionID string, + terminalID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + ptyWriter *io.Writer, + directOutputSet bool, +) bool { + if _, err := (*ptyWriter).Write(data); err != nil { + h.logger.Debug("PTY write error", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + // Try to re-establish PTY access + if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { + directOutputSet = true + if _, err := (*ptyWriter).Write(data); err != nil { + h.logger.Debug("PTY write error after reconnect", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), zap.Error(err)) - // Try to re-establish PTY access - if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, &ptyWriter) { - directOutputSet = true - // Retry write - if _, err := ptyWriter.Write(data); err != nil { - h.logger.Debug("PTY write error after reconnect", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Error(err)) - } - } - } - } else { - // PTY not ready yet - try to set it up - if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, &ptyWriter) { - directOutputSet = true - // Retry writing after setup - if _, err := ptyWriter.Write(data); err != nil { - h.logger.Debug("PTY write error after setup", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Error(err)) - } - } else { - h.logger.Debug("PTY not ready, dropping input", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Int("bytes", len(data))) } } } + return directOutputSet +} + +// writeToUnreadyUserShellPty sets up PTY access and writes input for a user shell. +// Drops the input if setup fails. +func (h *TerminalHandler) writeToUnreadyUserShellPty( + data []byte, + sessionID string, + terminalID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + ptyWriter *io.Writer, + directOutputSet bool, +) bool { + if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { + directOutputSet = true + if _, err := (*ptyWriter).Write(data); err != nil { + h.logger.Debug("PTY write error after setup", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + } else { + h.logger.Debug("PTY not ready, dropping input", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Int("bytes", len(data))) + } + return directOutputSet } // setupUserShellPtyAccess sets up PTY access for a user shell terminal. diff --git a/apps/backend/internal/integration/error_handling_integration_test.go b/apps/backend/internal/integration/error_handling_integration_test.go new file mode 100644 index 0000000000..7a6d701cbc --- /dev/null +++ b/apps/backend/internal/integration/error_handling_integration_test.go @@ -0,0 +1,66 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestErrorHandling(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + t.Run("UnknownAction", func(t *testing.T) { + resp, err := client.SendRequest("err-1", "unknown.action", map[string]interface{}{}) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeError, resp.Type) + + var payload ws.ErrorPayload + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.Equal(t, ws.ErrorCodeUnknownAction, payload.Code) + }) + + t.Run("GetNonExistentWorkflow", func(t *testing.T) { + resp, err := client.SendRequest("err-2", ws.ActionWorkflowGet, map[string]interface{}{ + "id": "non-existent-workflow-id", + }) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeError, resp.Type) + }) + + t.Run("GetNonExistentTask", func(t *testing.T) { + resp, err := client.SendRequest("err-3", ws.ActionTaskGet, map[string]interface{}{ + "id": "non-existent-task-id", + }) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeError, resp.Type) + }) + + t.Run("CreateTaskWithoutWorkflow", func(t *testing.T) { + resp, err := client.SendRequest("err-4", ws.ActionTaskCreate, map[string]interface{}{ + "title": "Orphan Task", + }) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeError, resp.Type) + }) + + t.Run("ListWorkflowStepsWithoutWorkflow", func(t *testing.T) { + resp, err := client.SendRequest("err-5", ws.ActionWorkflowStepList, map[string]interface{}{}) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeError, resp.Type) + }) +} diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index d0dd0506d0..e00b810e32 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -69,7 +69,7 @@ func NewTestServer(t *testing.T) *TestServer { sqlxDB := sqlx.NewDb(dbConn, "sqlite3") taskRepoImpl, cleanup, err := repository.Provide(sqlxDB) require.NoError(t, err) - taskRepo := repository.Repository(taskRepoImpl) + taskRepo := taskRepoImpl t.Cleanup(func() { if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) @@ -314,10 +314,6 @@ func (c *WSClient) WaitForNotification(action string, timeout time.Duration) (*w } } -// ============================================ -// HEALTH CHECK TESTS -// ============================================ - func TestHealthCheck(t *testing.T) { ts := NewTestServer(t) defer ts.Close() @@ -339,759 +335,3 @@ func TestHealthCheck(t *testing.T) { assert.Equal(t, "ok", payload["status"]) assert.Equal(t, "kandev", payload["service"]) } - -// ============================================ -// WORKFLOW TESTS -// ============================================ - -func TestWorkflowCRUD(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - - // Create workflow - t.Run("CreateWorkflow", func(t *testing.T) { - resp, err := client.SendRequest("workflow-create-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Test Workflow", - "description": "A test workflow for integration testing", - "workflow_template_id": "simple", - }) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.NotEmpty(t, payload["id"]) - assert.Equal(t, "Test Workflow", payload["name"]) - }) - - // List workflows - t.Run("ListWorkflows", func(t *testing.T) { - resp, err := client.SendRequest("workflow-list-1", ws.ActionWorkflowList, map[string]interface{}{ - "workspace_id": workspaceID, - }) - require.NoError(t, err) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - workflows, ok := payload["workflows"].([]interface{}) - require.True(t, ok) - assert.Len(t, workflows, 1) - }) -} - -// ============================================ -// WORKFLOW STEP TESTS -// ============================================ - -func TestWorkflowStepList(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - - // Create a workflow (workflow steps are created automatically with default template) - workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Workflow Step Test Workflow", - "workflow_template_id": "simple", - }) - require.NoError(t, err) - - var workflowPayload map[string]interface{} - err = workflowResp.ParsePayload(&workflowPayload) - require.NoError(t, err) - workflowID := workflowPayload["id"].(string) - - // List workflow steps - t.Run("ListWorkflowSteps", func(t *testing.T) { - resp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - steps, ok := payload["steps"].([]interface{}) - require.True(t, ok) - // Default workflow template creates 4 steps: Todo, In Progress, Review, Done - assert.GreaterOrEqual(t, len(steps), 1) - - // Verify first step has expected fields - firstStep := steps[0].(map[string]interface{}) - assert.NotEmpty(t, firstStep["id"]) - assert.NotEmpty(t, firstStep["name"]) - }) -} - -// ============================================ -// TASK TESTS -// ============================================ - -func TestTaskCRUD(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - repositoryID := createRepository(t, client, workspaceID) - - // Create workflow (workflow steps are created automatically) - workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Task Test Workflow", - "workflow_template_id": "simple", - }) - require.NoError(t, err) - - var workflowPayload map[string]interface{} - err = workflowResp.ParsePayload(&workflowPayload) - require.NoError(t, err) - workflowID := workflowPayload["id"].(string) - - // Get first workflow step - stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var stepListPayload map[string]interface{} - err = stepResp.ParsePayload(&stepListPayload) - require.NoError(t, err) - steps := stepListPayload["steps"].([]interface{}) - require.NotEmpty(t, steps) - workflowStepID := steps[0].(map[string]interface{})["id"].(string) - - var taskID string - - // Create task - t.Run("CreateTask", func(t *testing.T) { - resp, err := client.SendRequest("task-create-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": "Test Task", - "description": "A test task for integration testing", - "priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH) - "repository_id": repositoryID, - "base_branch": "main", - }) - require.NoError(t, err) - - // Debug: print error if there is one - if resp.Type == ws.MessageTypeError { - var errPayload ws.ErrorPayload - if err := resp.ParsePayload(&errPayload); err != nil { - t.Fatalf("failed to parse error payload: %v", err) - } - t.Logf("Error response: %+v", errPayload) - } - - require.Equal(t, ws.MessageTypeResponse, resp.Type, "Expected response but got %s", resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - taskID = payload["id"].(string) - assert.NotEmpty(t, taskID) - assert.Equal(t, "Test Task", payload["title"]) - assert.Equal(t, "CREATED", payload["state"]) - }) - - // Get task - t.Run("GetTask", func(t *testing.T) { - resp, err := client.SendRequest("task-get-1", ws.ActionTaskGet, map[string]interface{}{ - "id": taskID, - }) - require.NoError(t, err) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, taskID, payload["id"]) - assert.Equal(t, "Test Task", payload["title"]) - }) - - // Update task - t.Run("UpdateTask", func(t *testing.T) { - resp, err := client.SendRequest("task-update-1", ws.ActionTaskUpdate, map[string]interface{}{ - "id": taskID, - "title": "Updated Task Title", - }) - require.NoError(t, err) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, "Updated Task Title", payload["title"]) - }) - - // List tasks - t.Run("ListTasks", func(t *testing.T) { - resp, err := client.SendRequest("task-list-1", ws.ActionTaskList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - tasks, ok := payload["tasks"].([]interface{}) - require.True(t, ok) - assert.Len(t, tasks, 1) - }) - - // Delete task - t.Run("DeleteTask", func(t *testing.T) { - resp, err := client.SendRequest("task-delete-1", ws.ActionTaskDelete, map[string]interface{}{ - "id": taskID, - }) - require.NoError(t, err) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, true, payload["success"]) - - // Verify task is deleted - listResp, err := client.SendRequest("task-list-2", ws.ActionTaskList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var listPayload map[string]interface{} - err = listResp.ParsePayload(&listPayload) - require.NoError(t, err) - - tasks, ok := listPayload["tasks"].([]interface{}) - require.True(t, ok) - assert.Len(t, tasks, 0) - }) -} - -// ============================================ -// TASK STATE TESTS -// ============================================ - -func TestTaskStateTransitions(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - repositoryID := createRepository(t, client, workspaceID) - - // Create workflow and task (workflow steps are created automatically) - workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "State Test Workflow", - "workflow_template_id": "simple", - }) - var workflowPayload map[string]interface{} - if err := workflowResp.ParsePayload(&workflowPayload); err != nil { - t.Fatalf("failed to parse workflow payload: %v", err) - } - workflowID := workflowPayload["id"].(string) - - // Get first workflow step - stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - var stepListPayload map[string]interface{} - if err := stepResp.ParsePayload(&stepListPayload); err != nil { - t.Fatalf("failed to parse step list payload: %v", err) - } - steps := stepListPayload["steps"].([]interface{}) - workflowStepID := steps[0].(map[string]interface{})["id"].(string) - - taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": "State Test Task", - "description": "Test state transitions", - "repository_id": repositoryID, - "base_branch": "main", - }) - var taskPayload map[string]interface{} - if err := taskResp.ParsePayload(&taskPayload); err != nil { - t.Fatalf("failed to parse task payload: %v", err) - } - taskID := taskPayload["id"].(string) - - // Test state transitions - stateTests := []struct { - name string - newState string - }{ - {"ToInProgress", "IN_PROGRESS"}, - {"ToCompleted", "COMPLETED"}, - {"BackToTodo", "TODO"}, - {"ToBlocked", "BLOCKED"}, - } - - for i, tc := range stateTests { - t.Run(tc.name, func(t *testing.T) { - resp, err := client.SendRequest( - "state-"+string(rune('0'+i)), - ws.ActionTaskState, - map[string]interface{}{ - "id": taskID, - "state": tc.newState, - }, - ) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, tc.newState, payload["state"]) - }) - } -} - -// ============================================ -// TASK MOVE TESTS -// ============================================ - -func TestTaskMove(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - repositoryID := createRepository(t, client, workspaceID) - - // Create workflow (workflow steps are created automatically) - workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Move Test Workflow", - "workflow_template_id": "simple", - }) - var workflowPayload map[string]interface{} - if err := workflowResp.ParsePayload(&workflowPayload); err != nil { - t.Fatalf("failed to parse workflow payload: %v", err) - } - workflowID := workflowPayload["id"].(string) - - // Get workflow steps (at least 2 should exist from default template) - stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - var stepListPayload map[string]interface{} - if err := stepResp.ParsePayload(&stepListPayload); err != nil { - t.Fatalf("failed to parse step list payload: %v", err) - } - steps := stepListPayload["steps"].([]interface{}) - require.GreaterOrEqual(t, len(steps), 2, "Expected at least 2 workflow steps") - step1ID := steps[0].(map[string]interface{})["id"].(string) - step2ID := steps[1].(map[string]interface{})["id"].(string) - - // Create task in first step - taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": step1ID, - "title": "Movable Task", - "repository_id": repositoryID, - "base_branch": "main", - }) - var taskPayload map[string]interface{} - if err := taskResp.ParsePayload(&taskPayload); err != nil { - t.Fatalf("failed to parse task payload: %v", err) - } - taskID := taskPayload["id"].(string) - - // Move task to second step - t.Run("MoveToStep2", func(t *testing.T) { - resp, err := client.SendRequest("move-1", ws.ActionTaskMove, map[string]interface{}{ - "id": taskID, - "workflow_id": workflowID, - "workflow_step_id": step2ID, - "position": 0, - }) - require.NoError(t, err) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - // Response is {"task": {...}, "workflow_step": {...}} - taskData := payload["task"].(map[string]interface{}) - assert.Equal(t, step2ID, taskData["workflow_step_id"]) - }) - - // Verify task is now in step 2 - t.Run("VerifyMove", func(t *testing.T) { - resp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]interface{}{ - "id": taskID, - }) - require.NoError(t, err) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, step2ID, payload["workflow_step_id"]) - }) -} - -// ============================================ -// MULTIPLE CLIENTS TESTS -// ============================================ - -func TestMultipleClients(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client1 := NewWSClient(t, ts.Server.URL) - defer client1.Close() - - client2 := NewWSClient(t, ts.Server.URL) - defer client2.Close() - - workspaceID := createWorkspace(t, client1) - repositoryID := createRepository(t, client1, workspaceID) - - // Client 1 creates a workflow - workflowResp, err := client1.SendRequest("c1-workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Shared Workflow", - "workflow_template_id": "simple", - }) - require.NoError(t, err) - - var workflowPayload map[string]interface{} - err = workflowResp.ParsePayload(&workflowPayload) - require.NoError(t, err) - workflowID := workflowPayload["id"].(string) - - // Client 2 can see the workflow (filter by workspace to avoid default workflow) - listResp, err := client2.SendRequest("c2-list-1", ws.ActionWorkflowList, map[string]interface{}{ - "workspace_id": workspaceID, - }) - require.NoError(t, err) - - var listPayload map[string]interface{} - err = listResp.ParsePayload(&listPayload) - require.NoError(t, err) - - workflows, ok := listPayload["workflows"].([]interface{}) - require.True(t, ok) - assert.Len(t, workflows, 1) - - // Client 2 lists workflow steps (created automatically with workflow) - stepResp, err := client2.SendRequest("c2-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var stepPayload map[string]interface{} - err = stepResp.ParsePayload(&stepPayload) - require.NoError(t, err) - steps := stepPayload["steps"].([]interface{}) - require.NotEmpty(t, steps) - workflowStepID := steps[0].(map[string]interface{})["id"].(string) - - // Client 1 can also see the workflow steps - stepListResp, err := client1.SendRequest("c1-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var stepListPayload map[string]interface{} - err = stepListResp.ParsePayload(&stepListPayload) - require.NoError(t, err) - - stepsList, ok := stepListPayload["steps"].([]interface{}) - require.True(t, ok) - assert.GreaterOrEqual(t, len(stepsList), 1) - - // Client 1 creates a task in the workflow step - taskResp, err := client1.SendRequest("c1-task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": "Task by Client 1", - "repository_id": repositoryID, - "base_branch": "main", - }) - require.NoError(t, err) - - var taskPayload map[string]interface{} - err = taskResp.ParsePayload(&taskPayload) - require.NoError(t, err) - - // Client 2 can see the task - taskListResp, err := client2.SendRequest("c2-task-list-1", ws.ActionTaskList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var taskListPayload map[string]interface{} - err = taskListResp.ParsePayload(&taskListPayload) - require.NoError(t, err) - - tasks, ok := taskListPayload["tasks"].([]interface{}) - require.True(t, ok) - assert.Len(t, tasks, 1) -} - -// ============================================ -// ERROR HANDLING TESTS -// ============================================ - -func TestErrorHandling(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - t.Run("UnknownAction", func(t *testing.T) { - resp, err := client.SendRequest("err-1", "unknown.action", map[string]interface{}{}) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - - var payload ws.ErrorPayload - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, ws.ErrorCodeUnknownAction, payload.Code) - }) - - t.Run("GetNonExistentWorkflow", func(t *testing.T) { - resp, err := client.SendRequest("err-2", ws.ActionWorkflowGet, map[string]interface{}{ - "id": "non-existent-workflow-id", - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - }) - - t.Run("GetNonExistentTask", func(t *testing.T) { - resp, err := client.SendRequest("err-3", ws.ActionTaskGet, map[string]interface{}{ - "id": "non-existent-task-id", - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - }) - - t.Run("CreateTaskWithoutWorkflow", func(t *testing.T) { - resp, err := client.SendRequest("err-4", ws.ActionTaskCreate, map[string]interface{}{ - "title": "Orphan Task", - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - }) - - t.Run("ListWorkflowStepsWithoutWorkflow", func(t *testing.T) { - resp, err := client.SendRequest("err-5", ws.ActionWorkflowStepList, map[string]interface{}{}) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - }) -} - -// ============================================ -// TASK SUBSCRIPTION TESTS -// ============================================ - -func TestTaskSubscription(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - repositoryID := createRepository(t, client, workspaceID) - - // Create a task first (workflow steps are created automatically with workflow) - workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Sub Test Workflow", - "workflow_template_id": "simple", - }) - var workflowPayload map[string]interface{} - if err := workflowResp.ParsePayload(&workflowPayload); err != nil { - t.Fatalf("failed to parse workflow payload: %v", err) - } - workflowID := workflowPayload["id"].(string) - - // Get first workflow step - stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - var stepListPayload map[string]interface{} - if err := stepResp.ParsePayload(&stepListPayload); err != nil { - t.Fatalf("failed to parse step list payload: %v", err) - } - steps := stepListPayload["steps"].([]interface{}) - workflowStepID := steps[0].(map[string]interface{})["id"].(string) - - taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": "Subscribable Task", - "repository_id": repositoryID, - "base_branch": "main", - }) - var taskPayload map[string]interface{} - if err := taskResp.ParsePayload(&taskPayload); err != nil { - t.Fatalf("failed to parse task payload: %v", err) - } - taskID := taskPayload["id"].(string) - - // Subscribe to the task - t.Run("Subscribe", func(t *testing.T) { - resp, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, true, payload["success"]) - assert.Equal(t, taskID, payload["task_id"]) - }) - - // Unsubscribe from the task - t.Run("Unsubscribe", func(t *testing.T) { - resp, err := client.SendRequest("unsub-1", ws.ActionTaskUnsubscribe, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, true, payload["success"]) - }) -} - -// ============================================ -// CONCURRENT REQUEST TESTS -// ============================================ - -func TestConcurrentRequests(t *testing.T) { - ts := NewTestServer(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - repositoryID := createRepository(t, client, workspaceID) - - // Create workflow (workflow steps are created automatically) - workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Concurrent Test Workflow", - "workflow_template_id": "simple", - }) - var workflowPayload map[string]interface{} - if err := workflowResp.ParsePayload(&workflowPayload); err != nil { - t.Fatalf("failed to parse workflow payload: %v", err) - } - workflowID := workflowPayload["id"].(string) - - // Get first workflow step - stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - var stepListPayload map[string]interface{} - if err := stepResp.ParsePayload(&stepListPayload); err != nil { - t.Fatalf("failed to parse step list payload: %v", err) - } - steps := stepListPayload["steps"].([]interface{}) - workflowStepID := steps[0].(map[string]interface{})["id"].(string) - - // Create multiple tasks concurrently - numTasks := 10 - var wg sync.WaitGroup - results := make(chan error, numTasks) - - for i := 0; i < numTasks; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - - // Create separate client for concurrency - c := NewWSClient(t, ts.Server.URL) - defer c.Close() - - _, err := c.SendRequest( - "task-"+string(rune('0'+idx)), - ws.ActionTaskCreate, - map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": "Concurrent Task " + string(rune('0'+idx)), - "repository_id": repositoryID, - "base_branch": "main", - }, - ) - results <- err - }(i) - } - - wg.Wait() - close(results) - - // Verify all tasks were created - for err := range results { - assert.NoError(t, err) - } - - // Verify task count - listResp, err := client.SendRequest("list-1", ws.ActionTaskList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var listPayload map[string]interface{} - err = listResp.ParsePayload(&listPayload) - require.NoError(t, err) - - tasks, ok := listPayload["tasks"].([]interface{}) - require.True(t, ok) - assert.Len(t, tasks, numTasks) -} diff --git a/apps/backend/internal/integration/multi_client_integration_test.go b/apps/backend/internal/integration/multi_client_integration_test.go new file mode 100644 index 0000000000..f0acdc8fc2 --- /dev/null +++ b/apps/backend/internal/integration/multi_client_integration_test.go @@ -0,0 +1,276 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestMultipleClients(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client1 := NewWSClient(t, ts.Server.URL) + defer client1.Close() + + client2 := NewWSClient(t, ts.Server.URL) + defer client2.Close() + + workspaceID := createWorkspace(t, client1) + repositoryID := createRepository(t, client1, workspaceID) + + // Client 1 creates a workflow + workflowResp, err := client1.SendRequest("c1-workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Shared Workflow", + "workflow_template_id": "simple", + }) + require.NoError(t, err) + + var workflowPayload map[string]interface{} + err = workflowResp.ParsePayload(&workflowPayload) + require.NoError(t, err) + workflowID := workflowPayload["id"].(string) + + // Client 2 can see the workflow (filter by workspace to avoid default workflow) + listResp, err := client2.SendRequest("c2-list-1", ws.ActionWorkflowList, map[string]interface{}{ + "workspace_id": workspaceID, + }) + require.NoError(t, err) + + var listPayload map[string]interface{} + err = listResp.ParsePayload(&listPayload) + require.NoError(t, err) + + workflows, ok := listPayload["workflows"].([]interface{}) + require.True(t, ok) + assert.Len(t, workflows, 1) + + // Client 2 lists workflow steps (created automatically with workflow) + stepResp, err := client2.SendRequest("c2-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var stepPayload map[string]interface{} + err = stepResp.ParsePayload(&stepPayload) + require.NoError(t, err) + steps := stepPayload["steps"].([]interface{}) + require.NotEmpty(t, steps) + workflowStepID := steps[0].(map[string]interface{})["id"].(string) + + // Client 1 can also see the workflow steps + stepListResp, err := client1.SendRequest("c1-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var stepListPayload map[string]interface{} + err = stepListResp.ParsePayload(&stepListPayload) + require.NoError(t, err) + + stepsList, ok := stepListPayload["steps"].([]interface{}) + require.True(t, ok) + assert.GreaterOrEqual(t, len(stepsList), 1) + + // Client 1 creates a task in the workflow step + taskResp, err := client1.SendRequest("c1-task-1", ws.ActionTaskCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": "Task by Client 1", + "repository_id": repositoryID, + "base_branch": "main", + }) + require.NoError(t, err) + + var taskPayload map[string]interface{} + err = taskResp.ParsePayload(&taskPayload) + require.NoError(t, err) + + // Client 2 can see the task + taskListResp, err := client2.SendRequest("c2-task-list-1", ws.ActionTaskList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var taskListPayload map[string]interface{} + err = taskListResp.ParsePayload(&taskListPayload) + require.NoError(t, err) + + tasks, ok := taskListPayload["tasks"].([]interface{}) + require.True(t, ok) + assert.Len(t, tasks, 1) + + _ = taskPayload // suppress unused variable warning +} + +func TestConcurrentRequests(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) + + // Create workflow (workflow steps are created automatically) + workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Concurrent Test Workflow", + "workflow_template_id": "simple", + }) + var workflowPayload map[string]interface{} + if err := workflowResp.ParsePayload(&workflowPayload); err != nil { + t.Fatalf("failed to parse workflow payload: %v", err) + } + workflowID := workflowPayload["id"].(string) + + // Get first workflow step + stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + var stepListPayload map[string]interface{} + if err := stepResp.ParsePayload(&stepListPayload); err != nil { + t.Fatalf("failed to parse step list payload: %v", err) + } + steps := stepListPayload["steps"].([]interface{}) + workflowStepID := steps[0].(map[string]interface{})["id"].(string) + + // Create multiple tasks concurrently + numTasks := 10 + var wg sync.WaitGroup + results := make(chan error, numTasks) + + for i := 0; i < numTasks; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + // Create separate client for concurrency + c := NewWSClient(t, ts.Server.URL) + defer c.Close() + + _, err := c.SendRequest( + "task-"+string(rune('0'+idx)), + ws.ActionTaskCreate, + map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": "Concurrent Task " + string(rune('0'+idx)), + "repository_id": repositoryID, + "base_branch": "main", + }, + ) + results <- err + }(i) + } + + wg.Wait() + close(results) + + // Verify all tasks were created + for err := range results { + assert.NoError(t, err) + } + + // Verify task count + listResp, err := client.SendRequest("list-1", ws.ActionTaskList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var listPayload map[string]interface{} + err = listResp.ParsePayload(&listPayload) + require.NoError(t, err) + + tasks, ok := listPayload["tasks"].([]interface{}) + require.True(t, ok) + assert.Len(t, tasks, numTasks) +} + +func TestTaskSubscription(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) + + // Create a task first (workflow steps are created automatically with workflow) + workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Sub Test Workflow", + "workflow_template_id": "simple", + }) + var workflowPayload map[string]interface{} + if err := workflowResp.ParsePayload(&workflowPayload); err != nil { + t.Fatalf("failed to parse workflow payload: %v", err) + } + workflowID := workflowPayload["id"].(string) + + // Get first workflow step + stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + var stepListPayload map[string]interface{} + if err := stepResp.ParsePayload(&stepListPayload); err != nil { + t.Fatalf("failed to parse step list payload: %v", err) + } + steps := stepListPayload["steps"].([]interface{}) + workflowStepID := steps[0].(map[string]interface{})["id"].(string) + + taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": "Subscribable Task", + "repository_id": repositoryID, + "base_branch": "main", + }) + var taskPayload map[string]interface{} + if err := taskResp.ParsePayload(&taskPayload); err != nil { + t.Fatalf("failed to parse task payload: %v", err) + } + taskID := taskPayload["id"].(string) + + // Subscribe to the task + t.Run("Subscribe", func(t *testing.T) { + resp, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]interface{}{ + "task_id": taskID, + }) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.Equal(t, true, payload["success"]) + assert.Equal(t, taskID, payload["task_id"]) + }) + + // Unsubscribe from the task + t.Run("Unsubscribe", func(t *testing.T) { + resp, err := client.SendRequest("unsub-1", ws.ActionTaskUnsubscribe, map[string]interface{}{ + "task_id": taskID, + }) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.Equal(t, true, payload["success"]) + }) +} diff --git a/apps/backend/internal/integration/orchestrator_advanced_test.go b/apps/backend/internal/integration/orchestrator_advanced_test.go new file mode 100644 index 0000000000..989a874dc6 --- /dev/null +++ b/apps/backend/internal/integration/orchestrator_advanced_test.go @@ -0,0 +1,751 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/pkg/acp/protocol" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestOrchestratorTaskPriority(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + // Create tasks with different priorities + lowPriorityTask := ts.CreateTestTask(t, "augment-agent", 1) + highPriorityTask := ts.CreateTestTask(t, "augment-agent", 3) + medPriorityTask := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Start low priority first + _, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": lowPriorityTask, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + + // Then high priority + _, err = client.SendRequest("start-2", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": highPriorityTask, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + + // Then medium priority + _, err = client.SendRequest("start-3", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": medPriorityTask, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + + // All should start (we have capacity) + assert.Equal(t, 3, ts.AgentManager.GetLaunchCount()) +} + +func TestOrchestratorTriggerTask(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + resp, err := client.SendRequest("trigger-1", ws.ActionOrchestratorTrigger, map[string]interface{}{ + "task_id": taskID, + }) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + require.NoError(t, resp.ParsePayload(&payload)) + assert.True(t, payload["success"].(bool)) + assert.Equal(t, taskID, payload["task_id"]) +} + +func TestOrchestratorEndToEndWorkflow(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + workspaceID := createOrchestratorWorkspace(t, client) + + // 1. Create workflow with workflow template + workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "E2E Test Workflow", + "description": "End-to-end test workflow", + "workflow_template_id": "simple", + }) + require.NoError(t, err) + + var workflowPayload map[string]interface{} + require.NoError(t, workflowResp.ParsePayload(&workflowPayload)) + workflowID := workflowPayload["id"].(string) + + // 2. Get first workflow step from workflow + stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var stepListPayload map[string]interface{} + require.NoError(t, stepResp.ParsePayload(&stepListPayload)) + steps := stepListPayload["steps"].([]interface{}) + require.NotEmpty(t, steps, "workflow should have workflow steps") + workflowStepID := steps[0].(map[string]interface{})["id"].(string) + + // 3. Create task with workflow step + repoResp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Test Repo", + "source_type": "local", + "local_path": createTempRepoDir(t), + }) + require.NoError(t, err) + + var repoPayload map[string]interface{} + require.NoError(t, repoResp.ParsePayload(&repoPayload)) + repositoryID := repoPayload["id"].(string) + + taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": "Implement feature X", + "description": "Create a new feature with tests", + "priority": 2, + "repository_id": repositoryID, + "base_branch": "main", + }) + require.NoError(t, err) + + var taskPayload map[string]interface{} + require.NoError(t, taskResp.ParsePayload(&taskPayload)) + taskID := taskPayload["id"].(string) + + // 4. Subscribe to task + _, err = client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + + // 5. Start orchestrator + startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "test-profile-id", + }) + require.NoError(t, err) + + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + assert.True(t, startPayload["success"].(bool)) + agentExecutionID := startPayload["agent_execution_id"].(string) + assert.NotEmpty(t, agentExecutionID) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + + // 6. Collect notifications during execution + time.Sleep(400 * time.Millisecond) + notifications := client.CollectNotifications(200 * time.Millisecond) + t.Logf("Received %d notifications", len(notifications)) + + // 7. Complete the task + completeResp, err := client.SendRequest("complete-1", ws.ActionOrchestratorComplete, map[string]interface{}{ + "task_id": taskID, + }) + require.NoError(t, err) + + var completePayload map[string]interface{} + require.NoError(t, completeResp.ParsePayload(&completePayload)) + assert.True(t, completePayload["success"].(bool)) + + // 8. Verify final task state + getResp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]string{"id": taskID}) + require.NoError(t, err) + + var getPayload map[string]interface{} + require.NoError(t, getResp.ParsePayload(&getPayload)) + assert.Equal(t, "COMPLETED", getPayload["state"].(string)) +} + +// TestOrchestratorAgentMessagePersistence validates that agent messages are stored +// correctly in the database without missing data or ordering issues. +func TestOrchestratorAgentMessagePersistence(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + // This test verifies that message_streaming and log events ARE correctly persisted. + // Progress messages (protocol.MessageTypeProgress) are NOT persisted - only broadcast via WebSocket. + // Log messages (protocol.MessageTypeLog) ARE now persisted to the database. + + // We'll manually publish message_streaming and log events to test persistence + ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { + // Just emit a simple progress message - won't be stored + return []protocol.Message{ + { + Type: protocol.MessageTypeProgress, + TaskID: taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "progress": 10, + "stage": "starting", + "message": "Starting...", + }, + }, + } + }) + + // Create test task + taskID := ts.CreateTestTask(t, "test-profile-id", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Subscribe to task + _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + + // Start task execution + startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "test-profile-id", + }) + require.NoError(t, err) + + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + require.True(t, startPayload["success"].(bool), "Task should start successfully") + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID, "Session ID should be returned") + + // Subscribe to session for agent stream events + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + + // Wait for simulated agent to start + time.Sleep(200 * time.Millisecond) + + // Now manually publish message_streaming events which ARE persisted + // Generate unique message IDs for each streaming message + messageIDs := []string{ + "msg-" + uuid.New().String()[:8], + "msg-" + uuid.New().String()[:8], + "msg-" + uuid.New().String()[:8], + } + + expectedContents := []string{ + "I'm analyzing the codebase structure.", + "I found several files that need modification.", + "Here is my recommendation for the changes.", + } + + // Log messages to publish + logMessages := []struct { + Level string + Message string + }{ + {"info", "Found 10 files to process"}, + {"debug", "Processing file: main.go"}, + {"warning", "Deprecated API usage detected"}, + } + + // Publish message_streaming events - these ARE persisted + for i, msgID := range messageIDs { + payload := map[string]interface{}{ + "type": "agent/event", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "agent_id": "test-agent", + "task_id": taskID, + "session_id": sessionID, + "data": map[string]interface{}{ + "type": "message_streaming", + "text": expectedContents[i], + "message_id": msgID, + "is_append": false, // Each is a new message + }, + } + event := bus.NewEvent(events.AgentStream, "test", payload) + subject := events.BuildAgentStreamSubject(sessionID) + err := ts.EventBus.Publish(context.Background(), subject, event) + require.NoError(t, err) + time.Sleep(50 * time.Millisecond) + } + + // Publish log events - these ARE now persisted + for _, logMsg := range logMessages { + payload := map[string]interface{}{ + "type": "agent/event", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "agent_id": "test-agent", + "task_id": taskID, + "session_id": sessionID, + "data": map[string]interface{}{ + "type": "log", + "data": map[string]interface{}{ + "level": logMsg.Level, + "message": logMsg.Message, + }, + }, + } + event := bus.NewEvent(events.AgentStream, "test", payload) + subject := events.BuildAgentStreamSubject(sessionID) + err := ts.EventBus.Publish(context.Background(), subject, event) + require.NoError(t, err) + time.Sleep(50 * time.Millisecond) + } + + // Wait for all messages to be processed and stored + time.Sleep(300 * time.Millisecond) + + // Query the database to verify stored messages + storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID) + require.NoError(t, err) + + t.Logf("Found %d total stored messages", len(storedMessages)) + + type msgEntry struct { + ID string + Type string + Content string + AuthorType string + Metadata map[string]interface{} + CreatedAt time.Time + TaskSessionID string + TurnID string + } + var agentMessages []*msgEntry + for _, msg := range storedMessages { + if string(msg.AuthorType) == "agent" { + agentMessages = append(agentMessages, &msgEntry{ + ID: msg.ID, + Type: string(msg.Type), + Content: msg.Content, + AuthorType: string(msg.AuthorType), + Metadata: msg.Metadata, + CreatedAt: msg.CreatedAt, + TaskSessionID: msg.TaskSessionID, + TurnID: msg.TurnID, + }) + } + } + t.Logf("Found %d agent messages in database", len(agentMessages)) + + // Verify messages are stored + require.GreaterOrEqual(t, len(agentMessages), 3, "Should have at least 3 agent messages from streaming events") + + // Verify messages are in correct chronological order + for i := 1; i < len(agentMessages); i++ { + prev := agentMessages[i-1] + curr := agentMessages[i] + assert.True(t, !curr.CreatedAt.Before(prev.CreatedAt), + "Messages should be in chronological order: message %d (%s) at %v should not be before message %d (%s) at %v", + i, curr.ID, curr.CreatedAt, i-1, prev.ID, prev.CreatedAt) + } + + // Verify no duplicate messages exist (check for duplicate IDs) + seenIDs := make(map[string]bool) + for _, msg := range agentMessages { + assert.False(t, seenIDs[msg.ID], "Duplicate message ID found: %s", msg.ID) + seenIDs[msg.ID] = true + } + + // Verify all messages are associated with the correct TaskSessionID + for _, msg := range agentMessages { + assert.Equal(t, sessionID, msg.TaskSessionID, + "Message %s should be associated with session %s", msg.ID, sessionID) + } + + // Verify all agent messages have a TurnID + for _, msg := range agentMessages { + assert.NotEmpty(t, msg.TurnID, + "Message %s should have a TurnID", msg.ID) + } + + // Verify message content matches expected content + var contentMessages []*msgEntry + for _, msg := range agentMessages { + if msg.Type == "content" { + contentMessages = append(contentMessages, msg) + } + } + require.Len(t, contentMessages, 3, "Should have exactly 3 content messages") + + // Verify content is in correct order + for i, expected := range expectedContents { + if i < len(contentMessages) { + assert.Equal(t, expected, contentMessages[i].Content, + "Message %d content should match expected", i) + } + } + + // Log summary of stored messages for debugging + t.Logf("Message storage verification summary:") + for i, msg := range agentMessages { + t.Logf(" [%d] ID=%s Type=%s Content=%q CreatedAt=%v TurnID=%s", + i, msg.ID[:8], msg.Type, truncateString(msg.Content, 50), msg.CreatedAt, msg.TurnID[:8]) + } +} + +// TestOrchestratorAgentMessagePersistenceWithToolCalls validates that tool call messages +// are stored correctly with proper metadata including tool_call_id, title, status, and args. +func TestOrchestratorAgentMessagePersistenceWithToolCalls(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + // Configure simulated agent to emit tool call events via direct event publishing + toolCallID1 := "tc-" + uuid.New().String()[:8] + toolCallID2 := "tc-" + uuid.New().String()[:8] + + ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { + baseTime := time.Now() + return []protocol.Message{ + // Initial progress + { + Type: protocol.MessageTypeProgress, + TaskID: taskID, + Timestamp: baseTime, + Data: map[string]interface{}{ + "progress": 10, + "stage": "starting", + "message": "Starting tool execution...", + }, + }, + // Log message + { + Type: protocol.MessageTypeLog, + TaskID: taskID, + Timestamp: baseTime.Add(20 * time.Millisecond), + Data: map[string]interface{}{ + "level": "info", + "message": "Executing view tool", + }, + }, + // Final progress + { + Type: protocol.MessageTypeProgress, + TaskID: taskID, + Timestamp: baseTime.Add(100 * time.Millisecond), + Data: map[string]interface{}{ + "progress": 100, + "stage": "completed", + "message": "Tool execution completed", + }, + }, + } + }) + + // Create test task + taskID := ts.CreateTestTask(t, "test-profile-id", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Subscribe to task + _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + + // Start task execution + startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "test-profile-id", + }) + require.NoError(t, err) + + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + require.True(t, startPayload["success"].(bool)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + // Subscribe to session + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + + // Now publish tool call events directly to the event bus to simulate real tool usage + // This mimics what the lifecycle manager does when processing agent tool calls + publishToolCallEvent := func(toolCallID, title, status string, args map[string]interface{}) { + payload := map[string]interface{}{ + "type": "agent/event", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "agent_id": "test-agent", + "task_id": taskID, + "session_id": sessionID, + "data": map[string]interface{}{ + "type": "tool_call", + "tool_call_id": toolCallID, + "tool_title": title, + "tool_name": "view", + "tool_status": status, + "tool_args": args, + }, + } + event := bus.NewEvent(events.AgentStream, "test", payload) + subject := events.BuildAgentStreamSubject(sessionID) + err := ts.EventBus.Publish(context.Background(), subject, event) + require.NoError(t, err) + } + + publishToolUpdateEvent := func(toolCallID, status, result string) { + payload := map[string]interface{}{ + "type": "agent/event", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "agent_id": "test-agent", + "task_id": taskID, + "session_id": sessionID, + "data": map[string]interface{}{ + "type": "tool_update", + "tool_call_id": toolCallID, + "tool_status": status, + "tool_result": result, + }, + } + event := bus.NewEvent(events.AgentStream, "test", payload) + subject := events.BuildAgentStreamSubject(sessionID) + err := ts.EventBus.Publish(context.Background(), subject, event) + require.NoError(t, err) + } + + // Wait a bit for the initial messages to be processed + time.Sleep(300 * time.Millisecond) + + // Publish tool call start events + publishToolCallEvent(toolCallID1, "View main.go", "running", map[string]interface{}{ + "path": "main.go", + "kind": "view", + }) + time.Sleep(50 * time.Millisecond) + + publishToolCallEvent(toolCallID2, "View utils.go", "running", map[string]interface{}{ + "path": "utils.go", + "kind": "view", + }) + time.Sleep(50 * time.Millisecond) + + // Publish tool update events (completion) + publishToolUpdateEvent(toolCallID1, "complete", "File content: package main...") + time.Sleep(50 * time.Millisecond) + + publishToolUpdateEvent(toolCallID2, "complete", "File content: package utils...") + time.Sleep(50 * time.Millisecond) + + // Wait for processing + time.Sleep(500 * time.Millisecond) + + // Query stored messages + storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID) + require.NoError(t, err) + + t.Logf("Found %d total stored messages", len(storedMessages)) + + // Find tool_call messages + var toolCallMessages []*struct { + ID string + Type string + Content string + Metadata map[string]interface{} + } + for _, msg := range storedMessages { + if string(msg.Type) == "tool_call" { + toolCallMessages = append(toolCallMessages, &struct { + ID string + Type string + Content string + Metadata map[string]interface{} + }{ + ID: msg.ID, + Type: string(msg.Type), + Content: msg.Content, + Metadata: msg.Metadata, + }) + } + } + + t.Logf("Found %d tool_call messages", len(toolCallMessages)) + + // Verify tool call messages exist and have correct metadata + assert.GreaterOrEqual(t, len(toolCallMessages), 2, + "Should have at least 2 tool_call messages") + + // Verify tool call metadata structure + for _, msg := range toolCallMessages { + assert.NotNil(t, msg.Metadata, "Tool call message should have metadata") + if msg.Metadata != nil { + toolCallIDMeta, _ := msg.Metadata["tool_call_id"].(string) + assert.NotEmpty(t, toolCallIDMeta, + "Tool call message should have tool_call_id in metadata") + + title, _ := msg.Metadata["title"].(string) + t.Logf("Tool call message: ID=%s, tool_call_id=%s, title=%s, status=%v", + msg.ID[:8], toolCallIDMeta, title, msg.Metadata["status"]) + } + } +} + +// TestOrchestratorAgentMessageChunkStreaming validates that streaming message chunks +// are correctly aggregated and stored as complete messages. +func TestOrchestratorAgentMessageChunkStreaming(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + // Configure minimal ACP messages - we'll manually publish streaming chunks + ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { + return []protocol.Message{ + { + Type: protocol.MessageTypeProgress, + TaskID: taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "progress": 10, + "stage": "starting", + "message": "Starting...", + }, + }, + } + }) + + // Create test task + taskID := ts.CreateTestTask(t, "test-profile-id", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Subscribe and start task + _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + + startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "test-profile-id", + }) + require.NoError(t, err) + + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + require.True(t, startPayload["success"].(bool)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + + // Wait for initial setup + time.Sleep(300 * time.Millisecond) + + // Simulate streaming message chunks by publishing message_streaming events + messageID := uuid.New().String() + chunks := []string{ + "Hello, ", + "this is ", + "a streaming ", + "message that ", + "should be ", + "aggregated.", + } + + for i, chunk := range chunks { + payload := map[string]interface{}{ + "type": "agent/event", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "agent_id": "test-agent", + "task_id": taskID, + "session_id": sessionID, + "data": map[string]interface{}{ + "type": "message_streaming", + "text": chunk, + "message_id": messageID, + "is_append": i > 0, // First chunk creates, subsequent append + }, + } + event := bus.NewEvent(events.AgentStream, "test", payload) + subject := events.BuildAgentStreamSubject(sessionID) + err := ts.EventBus.Publish(context.Background(), subject, event) + require.NoError(t, err) + time.Sleep(30 * time.Millisecond) + } + + // Wait for processing + time.Sleep(500 * time.Millisecond) + + // Query stored messages + storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID) + require.NoError(t, err) + + // Find the streaming message + var streamingMessage *struct { + ID string + Content string + Type string + } + for _, msg := range storedMessages { + if msg.ID == messageID { + streamingMessage = &struct { + ID string + Content string + Type string + }{ + ID: msg.ID, + Content: msg.Content, + Type: string(msg.Type), + } + break + } + } + + // Verify the streaming message was created and aggregated + if streamingMessage != nil { + expectedContent := strings.Join(chunks, "") + assert.Equal(t, expectedContent, streamingMessage.Content, + "Streaming message content should be aggregated from all chunks") + t.Logf("Streaming message aggregated correctly: %q", streamingMessage.Content) + } else { + // The message might be stored with agent-generated ID + // Look for content messages from the agent + var agentContentMessages []*struct { + ID string + Content string + Type string + } + for _, msg := range storedMessages { + if string(msg.AuthorType) == "agent" && (string(msg.Type) == "message" || string(msg.Type) == "content") { + agentContentMessages = append(agentContentMessages, &struct { + ID string + Content string + Type string + }{ + ID: msg.ID, + Content: msg.Content, + Type: string(msg.Type), + }) + } + } + t.Logf("Found %d agent content messages", len(agentContentMessages)) + for _, msg := range agentContentMessages { + t.Logf(" ID=%s Type=%s Content=%q", msg.ID[:8], msg.Type, truncateString(msg.Content, 100)) + } + } +} + +// truncateString truncates a string to the specified length, appending "..." if truncated. +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/apps/backend/internal/integration/orchestrator_basic_test.go b/apps/backend/internal/integration/orchestrator_basic_test.go new file mode 100644 index 0000000000..10720e2597 --- /dev/null +++ b/apps/backend/internal/integration/orchestrator_basic_test.go @@ -0,0 +1,174 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/task/models" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestOrchestratorStatus(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + resp, err := client.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{}) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + require.NoError(t, resp.ParsePayload(&payload)) + + assert.True(t, payload["running"].(bool)) + assert.Equal(t, float64(0), payload["active_agents"]) + assert.Equal(t, float64(0), payload["queued_tasks"]) +} + +func TestOrchestratorQueue(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + resp, err := client.SendRequest("queue-1", ws.ActionOrchestratorQueue, map[string]interface{}{}) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + require.NoError(t, resp.ParsePayload(&payload)) + + assert.Equal(t, float64(0), payload["total"]) + tasks := payload["tasks"].([]interface{}) + assert.Len(t, tasks, 0) +} + +func TestOrchestratorStartTask(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + // Create a task with agent_profile_id + taskID := ts.CreateTestTask(t, "test-profile-id", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Subscribe to task notifications + subResp, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, subResp.Type) + + // Start task execution + resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "test-profile-id", + }) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + require.NoError(t, resp.ParsePayload(&payload)) + + assert.True(t, payload["success"].(bool)) + assert.Equal(t, taskID, payload["task_id"]) + assert.NotEmpty(t, payload["agent_execution_id"]) + sessionID, _ := payload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + + // Give the agent time to process + time.Sleep(500 * time.Millisecond) + + // Check status shows active agent + statusResp, err := client.SendRequest("status-2", ws.ActionOrchestratorStatus, map[string]interface{}{}) + require.NoError(t, err) + + var statusPayload map[string]interface{} + require.NoError(t, statusResp.ParsePayload(&statusPayload)) + assert.GreaterOrEqual(t, statusPayload["active_agents"].(float64), float64(0)) +} + +func TestOrchestratorStartTaskWithAgentTypeOverride(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + taskID := ts.CreateTestTask(t, "augment-agent", 1) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Start with different agent profile + resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "override-profile-id", + "priority": 3, + }) + require.NoError(t, err) + + var payload map[string]interface{} + require.NoError(t, resp.ParsePayload(&payload)) + + assert.True(t, payload["success"].(bool)) +} + +func TestOrchestratorStopTask(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Start task + startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + require.True(t, startPayload["success"].(bool)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + + // Wait for the session to become active before stopping + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + session, sErr := ts.TaskRepo.GetTaskSession(context.Background(), sessionID) + if sErr == nil && session.State == models.TaskSessionStateRunning { + break + } + time.Sleep(50 * time.Millisecond) + } + + // Stop task + stopResp, err := client.SendRequest("stop-1", ws.ActionOrchestratorStop, map[string]interface{}{ + "task_id": taskID, + "reason": "test stop", + "force": false, + }) + require.NoError(t, err) + + var stopPayload map[string]interface{} + require.NoError(t, stopResp.ParsePayload(&stopPayload)) + success, ok := stopPayload["success"].(bool) + assert.True(t, ok, "expected 'success' key in stop response, got: %v", stopPayload) + assert.True(t, success) +} diff --git a/apps/backend/internal/integration/orchestrator_interaction_test.go b/apps/backend/internal/integration/orchestrator_interaction_test.go new file mode 100644 index 0000000000..b7315f0493 --- /dev/null +++ b/apps/backend/internal/integration/orchestrator_interaction_test.go @@ -0,0 +1,444 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/pkg/acp/protocol" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestOrchestratorPromptTask(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Subscribe to task + _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + + // Start task + startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + require.True(t, startPayload["success"].(bool)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + + // Test 1: Verify that prompting while agent is RUNNING is rejected + time.Sleep(100 * time.Millisecond) // Give agent time to start + + // Attempt to send prompt while agent is still running - should be rejected + promptResp1, err := client.SendRequest("prompt-rejected", ws.ActionOrchestratorPrompt, map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "prompt": "This should be rejected", + }) + require.NoError(t, err) + + // Should receive an error response + var errorPayload map[string]interface{} + require.NoError(t, promptResp1.ParsePayload(&errorPayload)) + t.Logf("Prompt response payload: %+v", errorPayload) + // The response might be an error message type, check for common error fields + if errorMsg, hasError := errorPayload["error"]; hasError { + assert.Contains(t, errorMsg.(string), "processing", "Error message should mention agent is processing") + t.Log("Successfully rejected prompt while agent is running:", errorMsg) + } else if success, hasSuccess := errorPayload["success"]; hasSuccess { + assert.False(t, success.(bool), "Expected prompt to be rejected while agent is running") + } else if message, hasMessage := errorPayload["message"]; hasMessage { + assert.Contains(t, message.(string), "processing", "Error message should mention agent is processing") + t.Log("Successfully rejected prompt while agent is running:", message) + } else { + // Just log the payload and continue - the important thing is that the backend logged the error + t.Logf("Unexpected response format, but backend correctly rejected the prompt (see error log above)") + } + + // Test 2: Wait for agent to complete, then send a valid prompt + // Wait for agent to reach COMPLETED or WAITING_FOR_INPUT state + waitForSessionReady := func(timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + session, err := ts.TaskRepo.GetTaskSession(context.Background(), sessionID) + if err == nil && (session.State == models.TaskSessionStateWaitingForInput || session.State == models.TaskSessionStateCompleted) { + return true + } + time.Sleep(100 * time.Millisecond) + } + return false + } + + // Wait up to 5 seconds for session to be ready + if waitForSessionReady(5 * time.Second) { + // Now send a valid prompt + promptResp2, err := client.SendRequest("prompt-valid", ws.ActionOrchestratorPrompt, map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "prompt": "Please continue and add error handling", + }) + require.NoError(t, err) + + var promptPayload map[string]interface{} + require.NoError(t, promptResp2.ParsePayload(&promptPayload)) + assert.True(t, promptPayload["success"].(bool), "Prompt should succeed when agent is ready") + } +} + +func TestOrchestratorCompleteTask(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Start task + startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + require.True(t, startPayload["success"].(bool)) + + // Wait for agent to be running + time.Sleep(200 * time.Millisecond) + + // Complete task + completeResp, err := client.SendRequest("complete-1", ws.ActionOrchestratorComplete, map[string]interface{}{ + "task_id": taskID, + }) + require.NoError(t, err) + + var completePayload map[string]interface{} + require.NoError(t, completeResp.ParsePayload(&completePayload)) + assert.True(t, completePayload["success"].(bool)) + + // Verify task state is COMPLETED + task, err := ts.TaskRepo.GetTask(context.Background(), taskID) + require.NoError(t, err) + assert.Equal(t, v1.TaskStateCompleted, task.State) +} + +func TestOrchestratorConcurrentTasks(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + // Create multiple tasks + taskIDs := make([]string, 3) + for i := 0; i < 3; i++ { + taskIDs[i] = ts.CreateTestTask(t, "augment-agent", i+1) + } + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Start all tasks concurrently + var wg sync.WaitGroup + results := make(chan map[string]interface{}, 3) + + for i, taskID := range taskIDs { + wg.Add(1) + go func(idx int, tid string) { + defer wg.Done() + + resp, err := client.SendRequest(fmt.Sprintf("start-%d", idx), ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": tid, + "agent_profile_id": "augment-agent", + }) + if err != nil { + t.Logf("Error starting task %d: %v", idx, err) + return + } + + var payload map[string]interface{} + if err := resp.ParsePayload(&payload); err != nil { + t.Logf("failed to parse start response: %v", err) + return + } + results <- payload + }(i, taskID) + } + + wg.Wait() + close(results) + + // Count successful launches + successCount := 0 + for result := range results { + if success, ok := result["success"].(bool); ok && success { + successCount++ + } + } + + assert.Equal(t, 3, successCount, "All tasks should start successfully") + + // Check status shows multiple active agents + statusResp, err := client.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{}) + require.NoError(t, err) + + var statusPayload map[string]interface{} + require.NoError(t, statusResp.ParsePayload(&statusPayload)) + t.Logf("Active agents: %v", statusPayload["active_agents"]) +} + +func TestOrchestratorACPStreaming(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + // Configure simulated agent to emit specific ACP messages + ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { + return []protocol.Message{ + { + Type: protocol.MessageTypeProgress, + TaskID: taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "progress": 10, + "stage": "analyzing", + "message": "Analyzing codebase...", + }, + }, + { + Type: protocol.MessageTypeLog, + TaskID: taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "level": "info", + "message": "Found 5 files to modify", + }, + }, + { + Type: protocol.MessageTypeProgress, + TaskID: taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "progress": 50, + "stage": "modifying", + "message": "Modifying files...", + }, + }, + { + Type: protocol.MessageTypeProgress, + TaskID: taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "progress": 100, + "stage": "completed", + "message": "All modifications complete", + }, + }, + } + }) + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Subscribe to task + _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + + // Start task + startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + + // Extract session_id and subscribe to session for agent stream events + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + subResp, err := client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, subResp.Type, "Session subscription should succeed") + + // Wait for agent to process + time.Sleep(500 * time.Millisecond) + + // Verify orchestrator status shows active processing + statusResp, err := client.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, statusResp.Type) +} + +func TestOrchestratorMultipleClients(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + // Create two clients + client1 := NewOrchestratorWSClient(t, ts.Server.URL) + defer client1.Close() + + client2 := NewOrchestratorWSClient(t, ts.Server.URL) + defer client2.Close() + + // Both clients subscribe to the same task + _, err := client1.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + + _, err = client2.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) + require.NoError(t, err) + + // Client 1 starts the task + startResp, err := client1.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + var startPayload map[string]interface{} + require.NoError(t, startResp.ParsePayload(&startPayload)) + sessionID, _ := startPayload["session_id"].(string) + require.NotEmpty(t, sessionID) + + // Both clients subscribe to the session + subResp1, err := client1.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, subResp1.Type, "Client 1 subscription should succeed") + + subResp2, err := client2.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, subResp2.Type, "Client 2 subscription should succeed") + + // Verify both clients can query orchestrator status + status1, err := client1.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, status1.Type) + + status2, err := client2.SendRequest("status-2", ws.ActionOrchestratorStatus, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, status2.Type) +} + +func TestOrchestratorErrorHandling(t *testing.T) { + t.Run("StartTaskMissingTaskID", func(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{}) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeError, resp.Type) + + var errPayload ws.ErrorPayload + require.NoError(t, resp.ParsePayload(&errPayload)) + assert.Equal(t, ws.ErrorCodeValidation, errPayload.Code) + assert.Contains(t, errPayload.Message, "task_id") + }) + + t.Run("StartTaskNotFound", func(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": "non-existent-task", + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeError, resp.Type) + }) + + t.Run("PromptTaskMissingPrompt", func(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + resp, err := client.SendRequest("prompt-1", ws.ActionOrchestratorPrompt, map[string]interface{}{ + "task_id": taskID, + "session_id": "session-1", + }) + require.NoError(t, err) + + assert.Equal(t, ws.MessageTypeError, resp.Type) + + var errPayload ws.ErrorPayload + require.NoError(t, resp.ParsePayload(&errPayload)) + assert.Contains(t, errPayload.Message, "prompt") + }) + + t.Run("StopTaskNotRunning", func(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + // Try to stop a task that was never started + resp, err := client.SendRequest("stop-1", ws.ActionOrchestratorStop, map[string]interface{}{ + "task_id": taskID, + }) + require.NoError(t, err) + + // Should error because task is not running + assert.Equal(t, ws.MessageTypeError, resp.Type) + }) +} + +func TestOrchestratorAgentFailure(t *testing.T) { + ts := NewOrchestratorTestServer(t) + defer ts.Close() + + // Configure agent manager to fail launches + ts.AgentManager.SetShouldFail(true) + + taskID := ts.CreateTestTask(t, "augment-agent", 2) + + client := NewOrchestratorWSClient(t, ts.Server.URL) + defer client.Close() + + resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + "task_id": taskID, + "agent_profile_id": "augment-agent", + }) + require.NoError(t, err) + + // Should get error response + assert.Equal(t, ws.MessageTypeError, resp.Type) + + var errPayload ws.ErrorPayload + require.NoError(t, resp.ParsePayload(&errPayload)) + assert.Contains(t, errPayload.Message, "Failed to start task") +} diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go deleted file mode 100644 index db0460706f..0000000000 --- a/apps/backend/internal/integration/orchestrator_test.go +++ /dev/null @@ -1,2326 +0,0 @@ -// Package integration provides end-to-end integration tests for the Kandev backend. -// This file contains orchestrator-specific integration tests. -package integration - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/gorilla/websocket" - "github.com/jmoiron/sqlx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/db" - "github.com/kandev/kandev/internal/events" - "github.com/kandev/kandev/internal/events/bus" - gateways "github.com/kandev/kandev/internal/gateway/websocket" - "github.com/kandev/kandev/internal/orchestrator" - "github.com/kandev/kandev/internal/orchestrator/executor" - orchestratorhandlers "github.com/kandev/kandev/internal/orchestrator/handlers" - taskcontroller "github.com/kandev/kandev/internal/task/controller" - taskhandlers "github.com/kandev/kandev/internal/task/handlers" - "github.com/kandev/kandev/internal/task/models" - "github.com/kandev/kandev/internal/task/repository" - taskservice "github.com/kandev/kandev/internal/task/service" - "github.com/kandev/kandev/internal/workflow" - workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" - workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers" - workflowservice "github.com/kandev/kandev/internal/workflow/service" - "github.com/kandev/kandev/internal/worktree" - "github.com/kandev/kandev/pkg/acp/protocol" - v1 "github.com/kandev/kandev/pkg/api/v1" - ws "github.com/kandev/kandev/pkg/websocket" -) - -// ============================================ -// SIMULATED AGENT MANAGER -// ============================================ - -// SimulatedAgentManagerClient simulates agent container behavior for testing. -// It publishes realistic agent events (started, ACP messages, completion) to the event bus. -type SimulatedAgentManagerClient struct { - eventBus bus.EventBus - logger *logger.Logger - mu sync.Mutex - instances map[string]*simulatedInstance - launchDelay time.Duration - executionTime time.Duration - shouldFail bool - failAfter int // Fail after N successful launches - launchCount int32 - acpMessageFn func(taskID, executionID string) []protocol.Message // Custom ACP messages - stopCh chan struct{} -} - -// simulatedInstance tracks a simulated agent instance -type simulatedInstance struct { - id string - taskID string - sessionID string - agentProfileID string - status v1.AgentStatus - statusMu sync.Mutex // Protects status field - stopCh chan struct{} -} - -// NewSimulatedAgentManager creates a new simulated agent manager -func NewSimulatedAgentManager(eventBus bus.EventBus, log *logger.Logger) *SimulatedAgentManagerClient { - return &SimulatedAgentManagerClient{ - eventBus: eventBus, - logger: log, - instances: make(map[string]*simulatedInstance), - launchDelay: 50 * time.Millisecond, - executionTime: 200 * time.Millisecond, - stopCh: make(chan struct{}), - } -} - -// SetLaunchDelay sets the delay before agent "starts" -func (s *SimulatedAgentManagerClient) SetLaunchDelay(d time.Duration) { - s.launchDelay = d -} - -// SetExecutionTime sets how long the simulated task takes -func (s *SimulatedAgentManagerClient) SetExecutionTime(d time.Duration) { - s.executionTime = d -} - -// SetShouldFail configures whether launches should fail -func (s *SimulatedAgentManagerClient) SetShouldFail(fail bool) { - s.shouldFail = fail -} - -// SetFailAfter configures the agent to fail after N successful launches -func (s *SimulatedAgentManagerClient) SetFailAfter(n int) { - s.failAfter = n -} - -// SetACPMessageFn sets a custom function to generate ACP messages -func (s *SimulatedAgentManagerClient) SetACPMessageFn(fn func(taskID, executionID string) []protocol.Message) { - s.acpMessageFn = fn -} - -// LaunchAgent simulates launching an agent container -func (s *SimulatedAgentManagerClient) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { - count := atomic.AddInt32(&s.launchCount, 1) - - if s.shouldFail { - return nil, fmt.Errorf("simulated launch failure") - } - - if s.failAfter > 0 && int(count) > s.failAfter { - return nil, fmt.Errorf("simulated launch failure after %d attempts", s.failAfter) - } - - executionID := uuid.New().String() - containerID := "sim-container-" + executionID[:8] - - s.mu.Lock() - instance := &simulatedInstance{ - id: executionID, - taskID: req.TaskID, - sessionID: req.SessionID, - agentProfileID: req.AgentProfileID, - status: v1.AgentStatusStarting, - stopCh: make(chan struct{}), - } - s.instances[executionID] = instance - s.mu.Unlock() - - // Simulate agent lifecycle in background - go s.runAgentSimulation(instance, req) - - return &executor.LaunchAgentResponse{ - AgentExecutionID: executionID, - ContainerID: containerID, - Status: v1.AgentStatusStarting, - }, nil -} - -// SetExecutionDescription updates the task description for an execution -func (s *SimulatedAgentManagerClient) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error { - return nil -} - -// StartAgentProcess simulates starting the agent subprocess -func (s *SimulatedAgentManagerClient) StartAgentProcess(ctx context.Context, agentExecutionID string) error { - s.logger.Info("simulated: starting agent process", - zap.String("agent_execution_id", agentExecutionID)) - return nil -} - -// runAgentSimulation simulates the agent execution lifecycle -func (s *SimulatedAgentManagerClient) runAgentSimulation(instance *simulatedInstance, req *executor.LaunchAgentRequest) { - // Wait for launch delay - select { - case <-time.After(s.launchDelay): - case <-instance.stopCh: - return - case <-s.stopCh: - return - } - - // Publish agent started event - s.publishAgentEvent(events.AgentStarted, instance) - - // Simulate some ACP messages - s.publishACPMessages(instance, req) - - // Wait for execution time - select { - case <-time.After(s.executionTime): - case <-instance.stopCh: - s.publishAgentEvent(events.AgentStopped, instance) - return - case <-s.stopCh: - return - } - - // Publish agent ready (finished prompt, waiting for follow-up or completion) - instance.statusMu.Lock() - instance.status = v1.AgentStatusReady - instance.statusMu.Unlock() - s.publishAgentEvent(events.AgentReady, instance) -} - -// publishAgentEvent publishes an agent lifecycle event -func (s *SimulatedAgentManagerClient) publishAgentEvent(eventType string, instance *simulatedInstance) { - instance.statusMu.Lock() - status := instance.status - instance.statusMu.Unlock() - - data := map[string]interface{}{ - "instance_id": instance.id, - "task_id": instance.taskID, - "agent_profile_id": instance.agentProfileID, - "container_id": "sim-container-" + instance.id[:8], - "status": string(status), - "started_at": time.Now(), - "progress": 50, - } - - event := bus.NewEvent(eventType, "simulated-agent-manager", data) - if err := s.eventBus.Publish(context.Background(), eventType, event); err != nil { - s.logger.Error("failed to publish agent event") - } -} - -// publishACPMessages publishes simulated ACP messages -func (s *SimulatedAgentManagerClient) publishACPMessages(instance *simulatedInstance, req *executor.LaunchAgentRequest) { - var messages []protocol.Message - - if s.acpMessageFn != nil { - messages = s.acpMessageFn(instance.taskID, instance.id) - } else { - // Default ACP messages - messages = []protocol.Message{ - { - Type: protocol.MessageTypeProgress, - TaskID: instance.taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "progress": 25, - "stage": "starting", - "message": "Agent started processing task", - }, - }, - { - Type: protocol.MessageTypeLog, - TaskID: instance.taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "level": "info", - "message": "Processing task: " + req.TaskDescription, - }, - }, - { - Type: protocol.MessageTypeProgress, - TaskID: instance.taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "progress": 75, - "stage": "executing", - "message": "Task execution in progress", - }, - }, - } - } - - // Publish each message with a small delay - for _, msg := range messages { - sessionID := msg.SessionID - if sessionID == "" { - sessionID = instance.sessionID - } - msgData := map[string]interface{}{ - "type": msg.Type, - "task_id": msg.TaskID, - "session_id": sessionID, - "timestamp": msg.Timestamp, - "data": msg.Data, - } - - event := bus.NewEvent(events.AgentStream, "simulated-agent", msgData) - subject := events.BuildAgentStreamSubject(instance.taskID) - if err := s.eventBus.Publish(context.Background(), subject, event); err != nil { - s.logger.Error("failed to publish agent stream event") - } - - time.Sleep(20 * time.Millisecond) - } -} - -// StopAgent simulates stopping an agent -func (s *SimulatedAgentManagerClient) StopAgent(ctx context.Context, agentExecutionID string, force bool) error { - s.mu.Lock() - execution, exists := s.instances[agentExecutionID] - s.mu.Unlock() - - if !exists { - return fmt.Errorf("agent execution %q not found", agentExecutionID) - } - - close(execution.stopCh) - execution.statusMu.Lock() - execution.status = v1.AgentStatusStopped - execution.statusMu.Unlock() - - s.publishAgentEvent(events.AgentStopped, execution) - return nil -} - -// PromptAgent sends a follow-up prompt to a running agent -// Note: attachments parameter is accepted but not used in simulation -func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentExecutionID string, prompt string, _ []v1.MessageAttachment) (*executor.PromptResult, error) { - s.mu.Lock() - execution, exists := s.instances[agentExecutionID] - s.mu.Unlock() - - if !exists { - return nil, fmt.Errorf("agent execution %q not found", agentExecutionID) - } - - // Simulate receiving prompt and generating response - go func() { - time.Sleep(50 * time.Millisecond) - - msg := protocol.Message{ - Type: protocol.MessageTypeLog, - TaskID: execution.taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "level": "info", - "message": "Received follow-up prompt: " + prompt, - }, - } - - msgData := map[string]interface{}{ - "type": msg.Type, - "task_id": msg.TaskID, - "timestamp": msg.Timestamp, - "data": msg.Data, - } - - event := bus.NewEvent(events.AgentStream, "simulated-agent", msgData) - subject := events.BuildAgentStreamSubject(execution.taskID) - if err := s.eventBus.Publish(context.Background(), subject, event); err != nil { - s.logger.Warn("failed to publish simulated agent stream event", zap.Error(err)) - } - }() - - return &executor.PromptResult{ - StopReason: "end_turn", - }, nil -} - -// RespondToPermissionBySessionID responds to a permission request for a session -func (s *SimulatedAgentManagerClient) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { - s.logger.Info("simulated: responding to permission", - zap.String("session_id", sessionID), - zap.String("pending_id", pendingID), - zap.String("option_id", optionID), - zap.Bool("cancelled", cancelled)) - return nil -} - -// CompleteAgent marks an agent as completed -func (s *SimulatedAgentManagerClient) CompleteAgent(executionID string) { - s.mu.Lock() - execution, exists := s.instances[executionID] - s.mu.Unlock() - - if !exists { - return - } - - execution.status = v1.AgentStatusCompleted - s.publishAgentEvent(events.AgentCompleted, execution) -} - -// FailAgent marks an agent as failed -func (s *SimulatedAgentManagerClient) FailAgent(executionID string, reason string) { - s.mu.Lock() - execution, exists := s.instances[executionID] - s.mu.Unlock() - - if !exists { - return - } - - execution.status = v1.AgentStatusFailed - s.publishAgentEvent(events.AgentFailed, execution) -} - -// GetLaunchCount returns the number of times LaunchAgent was called -func (s *SimulatedAgentManagerClient) GetLaunchCount() int { - return int(atomic.LoadInt32(&s.launchCount)) -} - -// Close stops all simulated agents -func (s *SimulatedAgentManagerClient) Close() { - close(s.stopCh) -} - -// IsAgentRunningForSession checks if a simulated agent is running for a session -func (s *SimulatedAgentManagerClient) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { - s.mu.Lock() - defer s.mu.Unlock() - - for _, inst := range s.instances { - if inst.sessionID == sessionID && inst.status == v1.AgentStatusRunning { - return true - } - } - return false -} - -// CancelAgent cancels the current agent turn for a session -func (s *SimulatedAgentManagerClient) CancelAgent(ctx context.Context, sessionID string) error { - s.logger.Info("simulated: cancelling agent turn", - zap.String("session_id", sessionID)) - return nil -} - -// ResolveAgentProfile resolves an agent profile ID to profile information -func (s *SimulatedAgentManagerClient) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) { - return &executor.AgentProfileInfo{ - ProfileID: profileID, - ProfileName: "Simulated Profile", - AgentID: "augment-agent", - AgentName: "Augment Agent", - Model: "claude-sonnet-4-20250514", - }, nil -} - -func (s *SimulatedAgentManagerClient) IsPassthroughSession(ctx context.Context, sessionID string) bool { - return false -} - -// ============================================ -// ORCHESTRATOR TEST SERVER -// ============================================ - -// OrchestratorTestServer extends TestServer with orchestrator components -type OrchestratorTestServer struct { - Server *httptest.Server - Gateway *gateways.Gateway - TaskRepo repository.Repository - TaskSvc *taskservice.Service - WorkflowSvc *workflowservice.Service - EventBus bus.EventBus - OrchestratorSvc *orchestrator.Service - AgentManager *SimulatedAgentManagerClient - Logger *logger.Logger - ctx context.Context - cancelFunc context.CancelFunc -} - -// taskRepositoryAdapter adapts the task repository for the orchestrator -type taskRepositoryAdapter struct { - repo repository.Repository - svc *taskservice.Service -} - -func (a *taskRepositoryAdapter) GetTask(ctx context.Context, taskID string) (*v1.Task, error) { - task, err := a.repo.GetTask(ctx, taskID) - if err != nil { - return nil, err - } - return task.ToAPI(), nil -} - -func (a *taskRepositoryAdapter) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { - _, err := a.svc.UpdateTaskState(ctx, taskID, state) - return err -} - -// testMessageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface for tests -type testMessageCreatorAdapter struct { - svc *taskservice.Service -} - -func (a *testMessageCreatorAdapter) CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error { - _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ - TaskSessionID: agentSessionID, - TaskID: taskID, - TurnID: turnID, - Content: content, - AuthorType: "agent", - }) - return err -} - -func (a *testMessageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string, metadata map[string]interface{}) error { - _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ - TaskSessionID: agentSessionID, - TaskID: taskID, - TurnID: turnID, - Content: content, - AuthorType: "user", - Metadata: metadata, - }) - return err -} - -func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { - metadata := map[string]interface{}{ - "tool_call_id": toolCallID, - "title": title, - "status": status, - } - if parentToolCallID != "" { - metadata["parent_tool_call_id"] = parentToolCallID - } - if normalized != nil { - metadata["normalized"] = normalized - } - _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ - TaskSessionID: agentSessionID, - TaskID: taskID, - TurnID: turnID, - Content: title, - AuthorType: "agent", - Type: "tool_call", - Metadata: metadata, - }) - return err -} - -func (a *testMessageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error { - return a.svc.UpdateToolCallMessageWithCreate(ctx, agentSessionID, toolCallID, parentToolCallID, status, result, title, normalized, taskID, turnID, msgType) -} - -func (a *testMessageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error { - _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ - TaskSessionID: agentSessionID, - TaskID: taskID, - TurnID: turnID, - Content: content, - AuthorType: "agent", - Type: messageType, - Metadata: metadata, - RequestsInput: requestsInput, - }) - return err -} - -func (a *testMessageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) { - metadata := map[string]interface{}{ - "pending_id": pendingID, - "tool_call_id": toolCallID, - "options": options, - "action_type": actionType, - "action_details": actionDetails, - } - msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ - TaskSessionID: sessionID, - TaskID: taskID, - TurnID: turnID, - Content: title, - AuthorType: "agent", - Type: "permission_request", - Metadata: metadata, - }) - if err != nil { - return "", err - } - return msg.ID, nil -} - -func (a *testMessageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error { - return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status) -} - -func (a *testMessageCreatorAdapter) CreateAgentMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error { - _, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{ - TaskSessionID: agentSessionID, - TaskID: taskID, - TurnID: turnID, - Content: content, - AuthorType: "agent", - Type: "content", - }) - return err -} - -func (a *testMessageCreatorAdapter) AppendAgentMessage(ctx context.Context, messageID, additionalContent string) error { - return a.svc.AppendMessageContent(ctx, messageID, additionalContent) -} - -func (a *testMessageCreatorAdapter) CreateThinkingMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error { - _, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{ - TaskSessionID: agentSessionID, - TaskID: taskID, - TurnID: turnID, - Content: "", - AuthorType: "agent", - Type: "thinking", - Metadata: map[string]interface{}{ - "thinking": content, - }, - }) - return err -} - -func (a *testMessageCreatorAdapter) AppendThinkingMessage(ctx context.Context, messageID, additionalContent string) error { - return a.svc.AppendThinkingContent(ctx, messageID, additionalContent) -} - -// testTurnServiceAdapter adapts the task service to the orchestrator.TurnService interface for tests -type testTurnServiceAdapter struct { - svc *taskservice.Service -} - -func (a *testTurnServiceAdapter) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { - return a.svc.StartTurn(ctx, sessionID) -} - -func (a *testTurnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) error { - return a.svc.CompleteTurn(ctx, turnID) -} - -func (a *testTurnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) { - return a.svc.GetActiveTurn(ctx, sessionID) -} - -// NewOrchestratorTestServer creates a test server with full orchestrator support -func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { - t.Helper() - - // Initialize logger (quiet for tests) - log, err := logger.NewLogger(logger.LoggingConfig{ - Level: "error", - Format: "console", - }) - require.NoError(t, err) - - // Create context - ctx, cancel := context.WithCancel(context.Background()) - - // Initialize event bus - eventBus := bus.NewMemoryEventBus(log) - - tmpDir := t.TempDir() - dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) - require.NoError(t, err) - sqlxDB := sqlx.NewDb(dbConn, "sqlite3") - taskRepoImpl, cleanup, err := repository.Provide(sqlxDB) - require.NoError(t, err) - taskRepo := repository.Repository(taskRepoImpl) - t.Cleanup(func() { - if err := sqlxDB.Close(); err != nil { - t.Errorf("failed to close sqlite db: %v", err) - } - if cleanup != nil { - if err := cleanup(); err != nil { - t.Errorf("failed to close task repo: %v", err) - } - } - }) - if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { - t.Fatalf("failed to init worktree store: %v", err) - } - - // Initialize workflow service - _, workflowSvc, _, err := workflow.Provide(sqlxDB, log) - require.NoError(t, err) - - // Initialize task service and wire workflow step creator - taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) - taskSvc.SetWorkflowStepCreator(workflowSvc) - - // Create simulated agent manager - agentManager := NewSimulatedAgentManager(eventBus, log) - - // Create task repository adapter - taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc} - - // Create orchestrator service - cfg := orchestrator.DefaultServiceConfig() - cfg.Scheduler.ProcessInterval = 50 * time.Millisecond // Faster for tests - orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, nil, log) - - // Wire message creator for message persistence (similar to cmd/kandev/orchestrator.go) - msgCreator := &testMessageCreatorAdapter{svc: taskSvc} - orchestratorSvc.SetMessageCreator(msgCreator) - orchestratorSvc.SetTurnService(&testTurnServiceAdapter{svc: taskSvc}) - - // Create WebSocket gateway - gateway := gateways.NewGateway(log) - - // Register orchestrator handlers (Pattern A) - orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log) - orchestratorHandlers.RegisterHandlers(gateway.Dispatcher) - - // Start hub - go gateway.Hub.Run(ctx) - - // Register task notifications to broadcast events to WebSocket clients - gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) - - // Start orchestrator - require.NoError(t, orchestratorSvc.Start(ctx)) - - // Create router - gin.SetMode(gin.TestMode) - router := gin.New() - gateway.SetupRoutes(router) - - // Register handlers (HTTP + WS) - workspaceController := taskcontroller.NewWorkspaceController(taskSvc) - taskWorkflowController := taskcontroller.NewWorkflowController(taskSvc) - taskWorkflowController.SetWorkflowStepLister(workflowSvc) - taskController := taskcontroller.NewTaskController(taskSvc) - executorController := taskcontroller.NewExecutorController(taskSvc) - environmentController := taskcontroller.NewEnvironmentController(taskSvc) - repositoryController := taskcontroller.NewRepositoryController(taskSvc) - workflowCtrl := workflowcontroller.NewController(workflowSvc) - taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) - taskhandlers.RegisterWorkflowRoutes(router, gateway.Dispatcher, taskWorkflowController, log) - planService := taskservice.NewPlanService(taskRepo, eventBus, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, taskRepo, planService, log) - taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) - taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) - taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) - workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log) - - // Create test server - server := httptest.NewServer(router) - - return &OrchestratorTestServer{ - Server: server, - Gateway: gateway, - TaskRepo: taskRepo, - TaskSvc: taskSvc, - WorkflowSvc: workflowSvc, - EventBus: eventBus, - OrchestratorSvc: orchestratorSvc, - AgentManager: agentManager, - Logger: log, - ctx: ctx, - cancelFunc: cancel, - } -} - -// Close shuts down the test server -func (ts *OrchestratorTestServer) Close() { - if err := ts.OrchestratorSvc.Stop(); err != nil { - ts.Logger.Warn("failed to stop orchestrator", zap.Error(err)) - } - ts.AgentManager.Close() - ts.cancelFunc() - ts.Server.Close() - if err := ts.TaskRepo.Close(); err != nil { - ts.Logger.Warn("failed to close task repo", zap.Error(err)) - } - ts.EventBus.Close() -} - -// CreateTestTask creates a task for testing. -func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID string, priority int) string { - t.Helper() - - workspace, err := ts.TaskSvc.CreateWorkspace(context.Background(), &taskservice.CreateWorkspaceRequest{ - Name: "Test Workspace", - }) - require.NoError(t, err) - - // Create workflow first (workflow steps are created automatically via default template) - defaultTemplateID := "simple" - wf, err := ts.TaskSvc.CreateWorkflow(context.Background(), &taskservice.CreateWorkflowRequest{ - WorkspaceID: workspace.ID, - Name: "Test Workflow", - Description: "Test workflow for orchestrator", - WorkflowTemplateID: &defaultTemplateID, - }) - require.NoError(t, err) - - // Get first workflow step from workflow - steps, err := ts.WorkflowSvc.ListStepsByWorkflow(context.Background(), wf.ID) - require.NoError(t, err) - require.NotEmpty(t, steps, "workflow should have workflow steps") - workflowStepID := steps[0].ID - - // Create task with agent profile ID - repository, err := ts.TaskSvc.CreateRepository(context.Background(), &taskservice.CreateRepositoryRequest{ - WorkspaceID: workspace.ID, - Name: "Test Repo", - LocalPath: createTempRepoDir(t), - }) - require.NoError(t, err) - - task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{ - WorkspaceID: workspace.ID, - WorkflowID: wf.ID, - WorkflowStepID: workflowStepID, - Title: "Test Task", - Description: "This is a test task for the orchestrator", - Priority: priority, - Repositories: []taskservice.TaskRepositoryInput{ - { - RepositoryID: repository.ID, - BaseBranch: "main", - }, - }, - }) - require.NoError(t, err) - - return task.ID -} - -// OrchestratorWSClient is a WebSocket client for orchestrator tests -type OrchestratorWSClient struct { - conn *websocket.Conn - t *testing.T - notifications chan *ws.Message - done chan struct{} - // pending tracks in-flight requests: request ID -> response channel - pending map[string]chan *ws.Message - // send is the channel for outgoing messages (serialized through writePump) - send chan []byte - mu sync.Mutex -} - -// NewOrchestratorWSClient creates a WebSocket connection to the test server -func NewOrchestratorWSClient(t *testing.T, serverURL string) *OrchestratorWSClient { - t.Helper() - - wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + "/ws" - - dialer := websocket.Dialer{ - HandshakeTimeout: 5 * time.Second, - } - - conn, resp, err := dialer.Dial(wsURL, nil) - require.NoError(t, err) - require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) - - client := &OrchestratorWSClient{ - conn: conn, - t: t, - notifications: make(chan *ws.Message, 100), - done: make(chan struct{}), - pending: make(map[string]chan *ws.Message), - send: make(chan []byte, 256), - } - - go client.readPump() - go client.writePump() - - return client -} - -func createOrchestratorWorkspace(t *testing.T, client *OrchestratorWSClient) string { - t.Helper() - - resp, err := client.SendRequest("workspace-1", ws.ActionWorkspaceCreate, map[string]interface{}{ - "name": "Test Workspace", - }) - require.NoError(t, err) - - var payload map[string]interface{} - require.NoError(t, resp.ParsePayload(&payload)) - - return payload["id"].(string) -} - -// readPump reads messages from the WebSocket connection -func (c *OrchestratorWSClient) readPump() { - defer close(c.done) - for { - _, data, err := c.conn.ReadMessage() - if err != nil { - return - } - - var msg ws.Message - if err := json.Unmarshal(data, &msg); err != nil { - continue - } - - if msg.Type == ws.MessageTypeNotification { - select { - case c.notifications <- &msg: - default: - } - } else { - // Route response to the pending request by ID - c.mu.Lock() - ch, ok := c.pending[msg.ID] - c.mu.Unlock() - if ok { - select { - case ch <- &msg: - default: - } - } - } - } -} - -// writePump serializes all writes to the WebSocket connection -func (c *OrchestratorWSClient) writePump() { - for data := range c.send { - if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return - } - } -} - -// Close closes the WebSocket connection -func (c *OrchestratorWSClient) Close() { - close(c.send) - if err := c.conn.Close(); err != nil { - c.t.Logf("failed to close websocket: %v", err) - } - <-c.done -} - -// SendRequest sends a request and waits for a response -func (c *OrchestratorWSClient) SendRequest(id, action string, payload interface{}) (*ws.Message, error) { - msg, err := ws.NewRequest(id, action, payload) - if err != nil { - return nil, err - } - - data, err := json.Marshal(msg) - if err != nil { - return nil, err - } - - // Create a response channel for this request - respCh := make(chan *ws.Message, 1) - - // Register the pending request BEFORE sending (so we don't miss the response) - c.mu.Lock() - c.pending[id] = respCh - c.mu.Unlock() - - // Ensure we clean up when done - defer func() { - c.mu.Lock() - delete(c.pending, id) - c.mu.Unlock() - }() - - // Send the request through the write pump (serialized) - select { - case c.send <- data: - case <-time.After(5 * time.Second): - return nil, fmt.Errorf("send buffer full") - } - - // Wait for response on our dedicated channel - select { - case resp := <-respCh: - return resp, nil - case <-time.After(30 * time.Second): - return nil, context.DeadlineExceeded - } -} - -// WaitForNotification waits for a notification with the given action prefix -func (c *OrchestratorWSClient) WaitForNotification(actionPrefix string, timeout time.Duration) (*ws.Message, error) { - deadline := time.After(timeout) - for { - select { - case msg := <-c.notifications: - if strings.HasPrefix(msg.Action, actionPrefix) { - return msg, nil - } - case <-deadline: - return nil, context.DeadlineExceeded - } - } -} - -// CollectNotifications collects all notifications for a duration -func (c *OrchestratorWSClient) CollectNotifications(duration time.Duration) []*ws.Message { - var msgs []*ws.Message - deadline := time.After(duration) - for { - select { - case msg := <-c.notifications: - msgs = append(msgs, msg) - case <-deadline: - return msgs - } - } -} - -// ============================================ -// ORCHESTRATOR TESTS -// ============================================ - -func TestOrchestratorStatus(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{}) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - require.NoError(t, resp.ParsePayload(&payload)) - - assert.True(t, payload["running"].(bool)) - assert.Equal(t, float64(0), payload["active_agents"]) - assert.Equal(t, float64(0), payload["queued_tasks"]) -} - -func TestOrchestratorQueue(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("queue-1", ws.ActionOrchestratorQueue, map[string]interface{}{}) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - require.NoError(t, resp.ParsePayload(&payload)) - - assert.Equal(t, float64(0), payload["total"]) - tasks := payload["tasks"].([]interface{}) - assert.Len(t, tasks, 0) -} - -func TestOrchestratorStartTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // Create a task with agent_profile_id - taskID := ts.CreateTestTask(t, "test-profile-id", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Subscribe to task notifications - subResp, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, subResp.Type) - - // Start task execution - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "test-profile-id", - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - require.NoError(t, resp.ParsePayload(&payload)) - - assert.True(t, payload["success"].(bool)) - assert.Equal(t, taskID, payload["task_id"]) - assert.NotEmpty(t, payload["agent_execution_id"]) - sessionID, _ := payload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // Give the agent time to process - time.Sleep(500 * time.Millisecond) - - // Check status shows active agent - statusResp, err := client.SendRequest("status-2", ws.ActionOrchestratorStatus, map[string]interface{}{}) - require.NoError(t, err) - - var statusPayload map[string]interface{} - require.NoError(t, statusResp.ParsePayload(&statusPayload)) - assert.GreaterOrEqual(t, statusPayload["active_agents"].(float64), float64(0)) -} - -func TestOrchestratorStartTaskWithAgentTypeOverride(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 1) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Start with different agent profile - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "override-profile-id", - "priority": 3, - }) - require.NoError(t, err) - - var payload map[string]interface{} - require.NoError(t, resp.ParsePayload(&payload)) - - assert.True(t, payload["success"].(bool)) -} - -func TestOrchestratorStopTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // Wait for the session to become active before stopping - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - session, sErr := ts.TaskRepo.GetTaskSession(context.Background(), sessionID) - if sErr == nil && session.State == models.TaskSessionStateRunning { - break - } - time.Sleep(50 * time.Millisecond) - } - - // Stop task - stopResp, err := client.SendRequest("stop-1", ws.ActionOrchestratorStop, map[string]interface{}{ - "task_id": taskID, - "reason": "test stop", - "force": false, - }) - require.NoError(t, err) - - var stopPayload map[string]interface{} - require.NoError(t, stopResp.ParsePayload(&stopPayload)) - success, ok := stopPayload["success"].(bool) - assert.True(t, ok, "expected 'success' key in stop response, got: %v", stopPayload) - assert.True(t, success) -} - -func TestOrchestratorPromptTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Subscribe to task - _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // Test 1: Verify that prompting while agent is RUNNING is rejected - time.Sleep(100 * time.Millisecond) // Give agent time to start - - // Attempt to send prompt while agent is still running - should be rejected - promptResp1, err := client.SendRequest("prompt-rejected", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "prompt": "This should be rejected", - }) - require.NoError(t, err) - - // Should receive an error response - var errorPayload map[string]interface{} - require.NoError(t, promptResp1.ParsePayload(&errorPayload)) - t.Logf("Prompt response payload: %+v", errorPayload) - // The response might be an error message type, check for common error fields - if errorMsg, hasError := errorPayload["error"]; hasError { - assert.Contains(t, errorMsg.(string), "processing", "Error message should mention agent is processing") - t.Log("Successfully rejected prompt while agent is running:", errorMsg) - } else if success, hasSuccess := errorPayload["success"]; hasSuccess { - assert.False(t, success.(bool), "Expected prompt to be rejected while agent is running") - } else if message, hasMessage := errorPayload["message"]; hasMessage { - assert.Contains(t, message.(string), "processing", "Error message should mention agent is processing") - t.Log("Successfully rejected prompt while agent is running:", message) - } else { - // Just log the payload and continue - the important thing is that the backend logged the error - t.Logf("Unexpected response format, but backend correctly rejected the prompt (see error log above)") - } - - // Test 2: Wait for agent to complete, then send a valid prompt - // Wait for agent to reach COMPLETED or WAITING_FOR_INPUT state - waitForSessionReady := func(timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - session, err := ts.TaskRepo.GetTaskSession(context.Background(), sessionID) - if err == nil && (session.State == models.TaskSessionStateWaitingForInput || session.State == models.TaskSessionStateCompleted) { - return true - } - time.Sleep(100 * time.Millisecond) - } - return false - } - - // Wait up to 5 seconds for session to be ready - if waitForSessionReady(5 * time.Second) { - // Now send a valid prompt - promptResp2, err := client.SendRequest("prompt-valid", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "prompt": "Please continue and add error handling", - }) - require.NoError(t, err) - - var promptPayload map[string]interface{} - require.NoError(t, promptResp2.ParsePayload(&promptPayload)) - assert.True(t, promptPayload["success"].(bool), "Prompt should succeed when agent is ready") - } -} - -func TestOrchestratorCompleteTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - - // Wait for agent to be running - time.Sleep(200 * time.Millisecond) - - // Complete task - completeResp, err := client.SendRequest("complete-1", ws.ActionOrchestratorComplete, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - var completePayload map[string]interface{} - require.NoError(t, completeResp.ParsePayload(&completePayload)) - assert.True(t, completePayload["success"].(bool)) - - // Verify task state is COMPLETED - task, err := ts.TaskRepo.GetTask(context.Background(), taskID) - require.NoError(t, err) - assert.Equal(t, v1.TaskStateCompleted, task.State) -} - -func TestOrchestratorConcurrentTasks(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // Create multiple tasks - taskIDs := make([]string, 3) - for i := 0; i < 3; i++ { - taskIDs[i] = ts.CreateTestTask(t, "augment-agent", i+1) - } - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Start all tasks concurrently - var wg sync.WaitGroup - results := make(chan map[string]interface{}, 3) - - for i, taskID := range taskIDs { - wg.Add(1) - go func(idx int, tid string) { - defer wg.Done() - - resp, err := client.SendRequest(fmt.Sprintf("start-%d", idx), ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": tid, - "agent_profile_id": "augment-agent", - }) - if err != nil { - t.Logf("Error starting task %d: %v", idx, err) - return - } - - var payload map[string]interface{} - if err := resp.ParsePayload(&payload); err != nil { - t.Logf("failed to parse start response: %v", err) - return - } - results <- payload - }(i, taskID) - } - - wg.Wait() - close(results) - - // Count successful launches - successCount := 0 - for result := range results { - if success, ok := result["success"].(bool); ok && success { - successCount++ - } - } - - assert.Equal(t, 3, successCount, "All tasks should start successfully") - - // Check status shows multiple active agents - statusResp, err := client.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{}) - require.NoError(t, err) - - var statusPayload map[string]interface{} - require.NoError(t, statusResp.ParsePayload(&statusPayload)) - t.Logf("Active agents: %v", statusPayload["active_agents"]) -} - -func TestOrchestratorACPStreaming(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // Configure simulated agent to emit specific ACP messages - ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { - return []protocol.Message{ - { - Type: protocol.MessageTypeProgress, - TaskID: taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "progress": 10, - "stage": "analyzing", - "message": "Analyzing codebase...", - }, - }, - { - Type: protocol.MessageTypeLog, - TaskID: taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "level": "info", - "message": "Found 5 files to modify", - }, - }, - { - Type: protocol.MessageTypeProgress, - TaskID: taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "progress": 50, - "stage": "modifying", - "message": "Modifying files...", - }, - }, - { - Type: protocol.MessageTypeProgress, - TaskID: taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "progress": 100, - "stage": "completed", - "message": "All modifications complete", - }, - }, - } - }) - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Subscribe to task - _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - // Extract session_id and subscribe to session for agent stream events - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - subResp, err := client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, subResp.Type, "Session subscription should succeed") - - // Wait for agent to process - time.Sleep(500 * time.Millisecond) - - // Verify orchestrator status shows active processing - statusResp, err := client.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{}) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, statusResp.Type) -} - -func TestOrchestratorMultipleClients(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - // Create two clients - client1 := NewOrchestratorWSClient(t, ts.Server.URL) - defer client1.Close() - - client2 := NewOrchestratorWSClient(t, ts.Server.URL) - defer client2.Close() - - // Both clients subscribe to the same task - _, err := client1.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - _, err = client2.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // Client 1 starts the task - startResp, err := client1.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - // Both clients subscribe to the session - subResp1, err := client1.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, subResp1.Type, "Client 1 subscription should succeed") - - subResp2, err := client2.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, subResp2.Type, "Client 2 subscription should succeed") - - // Verify both clients can query orchestrator status - status1, err := client1.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{}) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, status1.Type) - - status2, err := client2.SendRequest("status-2", ws.ActionOrchestratorStatus, map[string]interface{}{}) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, status2.Type) -} - -func TestOrchestratorErrorHandling(t *testing.T) { - t.Run("StartTaskMissingTaskID", func(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{}) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - - var errPayload ws.ErrorPayload - require.NoError(t, resp.ParsePayload(&errPayload)) - assert.Equal(t, ws.ErrorCodeValidation, errPayload.Code) - assert.Contains(t, errPayload.Message, "task_id") - }) - - t.Run("StartTaskNotFound", func(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": "non-existent-task", - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - }) - - t.Run("PromptTaskMissingPrompt", func(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("prompt-1", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": "session-1", - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - - var errPayload ws.ErrorPayload - require.NoError(t, resp.ParsePayload(&errPayload)) - assert.Contains(t, errPayload.Message, "prompt") - }) - - t.Run("StopTaskNotRunning", func(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Try to stop a task that was never started - resp, err := client.SendRequest("stop-1", ws.ActionOrchestratorStop, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - // Should error because task is not running - assert.Equal(t, ws.MessageTypeError, resp.Type) - }) -} - -func TestOrchestratorAgentFailure(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // Configure agent manager to fail launches - ts.AgentManager.SetShouldFail(true) - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - // Should get error response - assert.Equal(t, ws.MessageTypeError, resp.Type) - - var errPayload ws.ErrorPayload - require.NoError(t, resp.ParsePayload(&errPayload)) - assert.Contains(t, errPayload.Message, "Failed to start task") -} - -func TestOrchestratorTaskPriority(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // Create tasks with different priorities - lowPriorityTask := ts.CreateTestTask(t, "augment-agent", 1) - highPriorityTask := ts.CreateTestTask(t, "augment-agent", 3) - medPriorityTask := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Start low priority first - _, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": lowPriorityTask, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - // Then high priority - _, err = client.SendRequest("start-2", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": highPriorityTask, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - // Then medium priority - _, err = client.SendRequest("start-3", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": medPriorityTask, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - // All should start (we have capacity) - assert.Equal(t, 3, ts.AgentManager.GetLaunchCount()) -} - -func TestOrchestratorTriggerTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("trigger-1", ws.ActionOrchestratorTrigger, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - require.NoError(t, resp.ParsePayload(&payload)) - assert.True(t, payload["success"].(bool)) - assert.Equal(t, taskID, payload["task_id"]) -} - -func TestOrchestratorEndToEndWorkflow(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createOrchestratorWorkspace(t, client) - - // 1. Create workflow with workflow template - workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "E2E Test Workflow", - "description": "End-to-end test workflow", - "workflow_template_id": "simple", - }) - require.NoError(t, err) - - var workflowPayload map[string]interface{} - require.NoError(t, workflowResp.ParsePayload(&workflowPayload)) - workflowID := workflowPayload["id"].(string) - - // 2. Get first workflow step from workflow - stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var stepListPayload map[string]interface{} - require.NoError(t, stepResp.ParsePayload(&stepListPayload)) - steps := stepListPayload["steps"].([]interface{}) - require.NotEmpty(t, steps, "workflow should have workflow steps") - workflowStepID := steps[0].(map[string]interface{})["id"].(string) - - // 3. Create task with workflow step - repoResp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Test Repo", - "source_type": "local", - "local_path": createTempRepoDir(t), - }) - require.NoError(t, err) - - var repoPayload map[string]interface{} - require.NoError(t, repoResp.ParsePayload(&repoPayload)) - repositoryID := repoPayload["id"].(string) - - taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": "Implement feature X", - "description": "Create a new feature with tests", - "priority": 2, - "repository_id": repositoryID, - "base_branch": "main", - }) - require.NoError(t, err) - - var taskPayload map[string]interface{} - require.NoError(t, taskResp.ParsePayload(&taskPayload)) - taskID := taskPayload["id"].(string) - - // 4. Subscribe to task - _, err = client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // 5. Start orchestrator - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "test-profile-id", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - assert.True(t, startPayload["success"].(bool)) - agentExecutionID := startPayload["agent_execution_id"].(string) - assert.NotEmpty(t, agentExecutionID) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // 6. Collect notifications during execution - time.Sleep(400 * time.Millisecond) - notifications := client.CollectNotifications(200 * time.Millisecond) - t.Logf("Received %d notifications", len(notifications)) - - // 7. Complete the task - completeResp, err := client.SendRequest("complete-1", ws.ActionOrchestratorComplete, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - var completePayload map[string]interface{} - require.NoError(t, completeResp.ParsePayload(&completePayload)) - assert.True(t, completePayload["success"].(bool)) - - // 8. Verify final task state - getResp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]string{"id": taskID}) - require.NoError(t, err) - - var getPayload map[string]interface{} - require.NoError(t, getResp.ParsePayload(&getPayload)) - assert.Equal(t, "COMPLETED", getPayload["state"].(string)) -} - -// TestOrchestratorAgentMessagePersistence validates that agent messages are stored -// correctly in the database without missing data or ordering issues. -func TestOrchestratorAgentMessagePersistence(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // This test verifies that message_streaming and log events ARE correctly persisted. - // Progress messages (protocol.MessageTypeProgress) are NOT persisted - only broadcast via WebSocket. - // Log messages (protocol.MessageTypeLog) ARE now persisted to the database. - - // We'll manually publish message_streaming and log events to test persistence - ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { - // Just emit a simple progress message - won't be stored - return []protocol.Message{ - { - Type: protocol.MessageTypeProgress, - TaskID: taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "progress": 10, - "stage": "starting", - "message": "Starting...", - }, - }, - } - }) - - // Create test task - taskID := ts.CreateTestTask(t, "test-profile-id", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Subscribe to task - _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // Start task execution - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "test-profile-id", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool), "Task should start successfully") - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID, "Session ID should be returned") - - // Subscribe to session for agent stream events - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // Wait for simulated agent to start - time.Sleep(200 * time.Millisecond) - - // Now manually publish message_streaming events which ARE persisted - // Generate unique message IDs for each streaming message - messageIDs := []string{ - "msg-" + uuid.New().String()[:8], - "msg-" + uuid.New().String()[:8], - "msg-" + uuid.New().String()[:8], - } - - expectedContents := []string{ - "I'm analyzing the codebase structure.", - "I found several files that need modification.", - "Here is my recommendation for the changes.", - } - - // Log messages to publish - logMessages := []struct { - Level string - Message string - }{ - {"info", "Found 10 files to process"}, - {"debug", "Processing file: main.go"}, - {"warning", "Deprecated API usage detected"}, - } - - // Publish message_streaming events - these ARE persisted - for i, msgID := range messageIDs { - payload := map[string]interface{}{ - "type": "agent/event", - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": "test-agent", - "task_id": taskID, - "session_id": sessionID, - "data": map[string]interface{}{ - "type": "message_streaming", - "text": expectedContents[i], - "message_id": msgID, - "is_append": false, // Each is a new message - }, - } - event := bus.NewEvent(events.AgentStream, "test", payload) - subject := events.BuildAgentStreamSubject(sessionID) - err := ts.EventBus.Publish(context.Background(), subject, event) - require.NoError(t, err) - time.Sleep(50 * time.Millisecond) - } - - // Publish log events - these ARE now persisted - for _, logMsg := range logMessages { - payload := map[string]interface{}{ - "type": "agent/event", - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": "test-agent", - "task_id": taskID, - "session_id": sessionID, - "data": map[string]interface{}{ - "type": "log", - "data": map[string]interface{}{ - "level": logMsg.Level, - "message": logMsg.Message, - }, - }, - } - event := bus.NewEvent(events.AgentStream, "test", payload) - subject := events.BuildAgentStreamSubject(sessionID) - err := ts.EventBus.Publish(context.Background(), subject, event) - require.NoError(t, err) - time.Sleep(50 * time.Millisecond) - } - - // Wait for all messages to be processed and stored - time.Sleep(300 * time.Millisecond) - - // Query the database to verify stored messages - storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID) - require.NoError(t, err) - - t.Logf("Found %d total stored messages", len(storedMessages)) - - // Filter to only agent messages (exclude initial user prompt) - var agentMessages []*struct { - ID string - Type string - Content string - AuthorType string - Metadata map[string]interface{} - CreatedAt time.Time - TaskSessionID string - TurnID string - } - for _, msg := range storedMessages { - if string(msg.AuthorType) == "agent" { - agentMessages = append(agentMessages, &struct { - ID string - Type string - Content string - AuthorType string - Metadata map[string]interface{} - CreatedAt time.Time - TaskSessionID string - TurnID string - }{ - ID: msg.ID, - Type: string(msg.Type), - Content: msg.Content, - AuthorType: string(msg.AuthorType), - Metadata: msg.Metadata, - CreatedAt: msg.CreatedAt, - TaskSessionID: msg.TaskSessionID, - TurnID: msg.TurnID, - }) - } - } - - t.Logf("Found %d agent messages in database", len(agentMessages)) - - // Verify messages are stored - require.GreaterOrEqual(t, len(agentMessages), 3, "Should have at least 3 agent messages from streaming events") - - // Verify messages are in correct chronological order - for i := 1; i < len(agentMessages); i++ { - prev := agentMessages[i-1] - curr := agentMessages[i] - assert.True(t, !curr.CreatedAt.Before(prev.CreatedAt), - "Messages should be in chronological order: message %d (%s) at %v should not be before message %d (%s) at %v", - i, curr.ID, curr.CreatedAt, i-1, prev.ID, prev.CreatedAt) - } - - // Verify no duplicate messages exist (check for duplicate IDs) - seenIDs := make(map[string]bool) - for _, msg := range agentMessages { - assert.False(t, seenIDs[msg.ID], "Duplicate message ID found: %s", msg.ID) - seenIDs[msg.ID] = true - } - - // Verify all messages are associated with the correct TaskSessionID - for _, msg := range agentMessages { - assert.Equal(t, sessionID, msg.TaskSessionID, - "Message %s should be associated with session %s", msg.ID, sessionID) - } - - // Verify all agent messages have a TurnID - for _, msg := range agentMessages { - assert.NotEmpty(t, msg.TurnID, - "Message %s should have a TurnID", msg.ID) - } - - // Verify message content matches expected content - contentMessages := make([]*struct { - ID string - Type string - Content string - AuthorType string - Metadata map[string]interface{} - CreatedAt time.Time - TaskSessionID string - TurnID string - }, 0) - for _, msg := range agentMessages { - if msg.Type == "content" { - contentMessages = append(contentMessages, msg) - } - } - require.Len(t, contentMessages, 3, "Should have exactly 3 content messages") - - // Verify content is in correct order - for i, expected := range expectedContents { - if i < len(contentMessages) { - assert.Equal(t, expected, contentMessages[i].Content, - "Message %d content should match expected", i) - } - } - - // Log summary of stored messages for debugging - t.Logf("Message storage verification summary:") - for i, msg := range agentMessages { - t.Logf(" [%d] ID=%s Type=%s Content=%q CreatedAt=%v TurnID=%s", - i, msg.ID[:8], msg.Type, truncateString(msg.Content, 50), msg.CreatedAt, msg.TurnID[:8]) - } -} - -// TestOrchestratorAgentMessagePersistenceWithToolCalls validates that tool call messages -// are stored correctly with proper metadata including tool_call_id, title, status, and args. -func TestOrchestratorAgentMessagePersistenceWithToolCalls(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // Configure simulated agent to emit tool call events via direct event publishing - toolCallID1 := "tc-" + uuid.New().String()[:8] - toolCallID2 := "tc-" + uuid.New().String()[:8] - - ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { - baseTime := time.Now() - return []protocol.Message{ - // Initial progress - { - Type: protocol.MessageTypeProgress, - TaskID: taskID, - Timestamp: baseTime, - Data: map[string]interface{}{ - "progress": 10, - "stage": "starting", - "message": "Starting tool execution...", - }, - }, - // Log message - { - Type: protocol.MessageTypeLog, - TaskID: taskID, - Timestamp: baseTime.Add(20 * time.Millisecond), - Data: map[string]interface{}{ - "level": "info", - "message": "Executing view tool", - }, - }, - // Final progress - { - Type: protocol.MessageTypeProgress, - TaskID: taskID, - Timestamp: baseTime.Add(100 * time.Millisecond), - Data: map[string]interface{}{ - "progress": 100, - "stage": "completed", - "message": "Tool execution completed", - }, - }, - } - }) - - // Create test task - taskID := ts.CreateTestTask(t, "test-profile-id", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Subscribe to task - _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // Start task execution - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "test-profile-id", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - // Subscribe to session - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // Now publish tool call events directly to the event bus to simulate real tool usage - // This mimics what the lifecycle manager does when processing agent tool calls - publishToolCallEvent := func(toolCallID, title, status string, args map[string]interface{}) { - payload := map[string]interface{}{ - "type": "agent/event", - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": "test-agent", - "task_id": taskID, - "session_id": sessionID, - "data": map[string]interface{}{ - "type": "tool_call", - "tool_call_id": toolCallID, - "tool_title": title, - "tool_name": "view", - "tool_status": status, - "tool_args": args, - }, - } - event := bus.NewEvent(events.AgentStream, "test", payload) - subject := events.BuildAgentStreamSubject(sessionID) - err := ts.EventBus.Publish(context.Background(), subject, event) - require.NoError(t, err) - } - - publishToolUpdateEvent := func(toolCallID, status, result string) { - payload := map[string]interface{}{ - "type": "agent/event", - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": "test-agent", - "task_id": taskID, - "session_id": sessionID, - "data": map[string]interface{}{ - "type": "tool_update", - "tool_call_id": toolCallID, - "tool_status": status, - "tool_result": result, - }, - } - event := bus.NewEvent(events.AgentStream, "test", payload) - subject := events.BuildAgentStreamSubject(sessionID) - err := ts.EventBus.Publish(context.Background(), subject, event) - require.NoError(t, err) - } - - // Wait a bit for the initial messages to be processed - time.Sleep(300 * time.Millisecond) - - // Publish tool call start events - publishToolCallEvent(toolCallID1, "View main.go", "running", map[string]interface{}{ - "path": "main.go", - "kind": "view", - }) - time.Sleep(50 * time.Millisecond) - - publishToolCallEvent(toolCallID2, "View utils.go", "running", map[string]interface{}{ - "path": "utils.go", - "kind": "view", - }) - time.Sleep(50 * time.Millisecond) - - // Publish tool update events (completion) - publishToolUpdateEvent(toolCallID1, "complete", "File content: package main...") - time.Sleep(50 * time.Millisecond) - - publishToolUpdateEvent(toolCallID2, "complete", "File content: package utils...") - time.Sleep(50 * time.Millisecond) - - // Wait for processing - time.Sleep(500 * time.Millisecond) - - // Query stored messages - storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID) - require.NoError(t, err) - - t.Logf("Found %d total stored messages", len(storedMessages)) - - // Find tool_call messages - var toolCallMessages []*struct { - ID string - Type string - Content string - Metadata map[string]interface{} - } - for _, msg := range storedMessages { - if string(msg.Type) == "tool_call" { - toolCallMessages = append(toolCallMessages, &struct { - ID string - Type string - Content string - Metadata map[string]interface{} - }{ - ID: msg.ID, - Type: string(msg.Type), - Content: msg.Content, - Metadata: msg.Metadata, - }) - } - } - - t.Logf("Found %d tool_call messages", len(toolCallMessages)) - - // Verify tool call messages exist and have correct metadata - assert.GreaterOrEqual(t, len(toolCallMessages), 2, - "Should have at least 2 tool_call messages") - - // Verify tool call metadata structure - for _, msg := range toolCallMessages { - assert.NotNil(t, msg.Metadata, "Tool call message should have metadata") - if msg.Metadata != nil { - toolCallIDMeta, _ := msg.Metadata["tool_call_id"].(string) - assert.NotEmpty(t, toolCallIDMeta, - "Tool call message should have tool_call_id in metadata") - - title, _ := msg.Metadata["title"].(string) - t.Logf("Tool call message: ID=%s, tool_call_id=%s, title=%s, status=%v", - msg.ID[:8], toolCallIDMeta, title, msg.Metadata["status"]) - } - } -} - -// TestOrchestratorAgentMessageChunkStreaming validates that streaming message chunks -// are correctly aggregated and stored as complete messages. -func TestOrchestratorAgentMessageChunkStreaming(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // Configure minimal ACP messages - we'll manually publish streaming chunks - ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { - return []protocol.Message{ - { - Type: protocol.MessageTypeProgress, - TaskID: taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "progress": 10, - "stage": "starting", - "message": "Starting...", - }, - }, - } - }) - - // Create test task - taskID := ts.CreateTestTask(t, "test-profile-id", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Subscribe and start task - _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "test-profile-id", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // Wait for initial setup - time.Sleep(300 * time.Millisecond) - - // Simulate streaming message chunks by publishing message_streaming events - messageID := uuid.New().String() - chunks := []string{ - "Hello, ", - "this is ", - "a streaming ", - "message that ", - "should be ", - "aggregated.", - } - - for i, chunk := range chunks { - payload := map[string]interface{}{ - "type": "agent/event", - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": "test-agent", - "task_id": taskID, - "session_id": sessionID, - "data": map[string]interface{}{ - "type": "message_streaming", - "text": chunk, - "message_id": messageID, - "is_append": i > 0, // First chunk creates, subsequent append - }, - } - event := bus.NewEvent(events.AgentStream, "test", payload) - subject := events.BuildAgentStreamSubject(sessionID) - err := ts.EventBus.Publish(context.Background(), subject, event) - require.NoError(t, err) - time.Sleep(30 * time.Millisecond) - } - - // Wait for processing - time.Sleep(500 * time.Millisecond) - - // Query stored messages - storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID) - require.NoError(t, err) - - // Find the streaming message - var streamingMessage *struct { - ID string - Content string - Type string - } - for _, msg := range storedMessages { - if msg.ID == messageID { - streamingMessage = &struct { - ID string - Content string - Type string - }{ - ID: msg.ID, - Content: msg.Content, - Type: string(msg.Type), - } - break - } - } - - // Verify the streaming message was created and aggregated - if streamingMessage != nil { - expectedContent := strings.Join(chunks, "") - assert.Equal(t, expectedContent, streamingMessage.Content, - "Streaming message content should be aggregated from all chunks") - t.Logf("Streaming message aggregated correctly: %q", streamingMessage.Content) - } else { - // The message might be stored with agent-generated ID - // Look for content messages from the agent - var agentContentMessages []*struct { - ID string - Content string - Type string - } - for _, msg := range storedMessages { - if string(msg.AuthorType) == "agent" && (string(msg.Type) == "message" || string(msg.Type) == "content") { - agentContentMessages = append(agentContentMessages, &struct { - ID string - Content string - Type string - }{ - ID: msg.ID, - Content: msg.Content, - Type: string(msg.Type), - }) - } - } - t.Logf("Found %d agent content messages", len(agentContentMessages)) - for _, msg := range agentContentMessages { - t.Logf(" ID=%s Type=%s Content=%q", msg.ID[:8], msg.Type, truncateString(msg.Content, 100)) - } - } -} - -// truncateString truncates a string to the specified length, appending "..." if truncated. -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "..." -} diff --git a/apps/backend/internal/integration/simulated_agent_manager_test.go b/apps/backend/internal/integration/simulated_agent_manager_test.go new file mode 100644 index 0000000000..2e23973419 --- /dev/null +++ b/apps/backend/internal/integration/simulated_agent_manager_test.go @@ -0,0 +1,400 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/pkg/acp/protocol" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// SimulatedAgentManagerClient simulates agent container behavior for testing. +// It publishes realistic agent events (started, ACP messages, completion) to the event bus. +type SimulatedAgentManagerClient struct { + eventBus bus.EventBus + logger *logger.Logger + mu sync.Mutex + instances map[string]*simulatedInstance + launchDelay time.Duration + executionTime time.Duration + shouldFail bool + failAfter int // Fail after N successful launches + launchCount int32 + acpMessageFn func(taskID, executionID string) []protocol.Message // Custom ACP messages + stopCh chan struct{} +} + +// simulatedInstance tracks a simulated agent instance +type simulatedInstance struct { + id string + taskID string + sessionID string + agentProfileID string + status v1.AgentStatus + statusMu sync.Mutex // Protects status field + stopCh chan struct{} +} + +// NewSimulatedAgentManager creates a new simulated agent manager +func NewSimulatedAgentManager(eventBus bus.EventBus, log *logger.Logger) *SimulatedAgentManagerClient { + return &SimulatedAgentManagerClient{ + eventBus: eventBus, + logger: log, + instances: make(map[string]*simulatedInstance), + launchDelay: 50 * time.Millisecond, + executionTime: 200 * time.Millisecond, + stopCh: make(chan struct{}), + } +} + +// SetLaunchDelay sets the delay before agent "starts" +func (s *SimulatedAgentManagerClient) SetLaunchDelay(d time.Duration) { + s.launchDelay = d +} + +// SetExecutionTime sets how long the simulated task takes +func (s *SimulatedAgentManagerClient) SetExecutionTime(d time.Duration) { + s.executionTime = d +} + +// SetShouldFail configures whether launches should fail +func (s *SimulatedAgentManagerClient) SetShouldFail(fail bool) { + s.shouldFail = fail +} + +// SetFailAfter configures the agent to fail after N successful launches +func (s *SimulatedAgentManagerClient) SetFailAfter(n int) { + s.failAfter = n +} + +// SetACPMessageFn sets a custom function to generate ACP messages +func (s *SimulatedAgentManagerClient) SetACPMessageFn(fn func(taskID, executionID string) []protocol.Message) { + s.acpMessageFn = fn +} + +// LaunchAgent simulates launching an agent container +func (s *SimulatedAgentManagerClient) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + count := atomic.AddInt32(&s.launchCount, 1) + + if s.shouldFail { + return nil, fmt.Errorf("simulated launch failure") + } + + if s.failAfter > 0 && int(count) > s.failAfter { + return nil, fmt.Errorf("simulated launch failure after %d attempts", s.failAfter) + } + + executionID := uuid.New().String() + containerID := "sim-container-" + executionID[:8] + + s.mu.Lock() + instance := &simulatedInstance{ + id: executionID, + taskID: req.TaskID, + sessionID: req.SessionID, + agentProfileID: req.AgentProfileID, + status: v1.AgentStatusStarting, + stopCh: make(chan struct{}), + } + s.instances[executionID] = instance + s.mu.Unlock() + + // Simulate agent lifecycle in background + go s.runAgentSimulation(instance, req) + + return &executor.LaunchAgentResponse{ + AgentExecutionID: executionID, + ContainerID: containerID, + Status: v1.AgentStatusStarting, + }, nil +} + +// SetExecutionDescription updates the task description for an execution +func (s *SimulatedAgentManagerClient) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error { + return nil +} + +// StartAgentProcess simulates starting the agent subprocess +func (s *SimulatedAgentManagerClient) StartAgentProcess(ctx context.Context, agentExecutionID string) error { + s.logger.Info("simulated: starting agent process", + zap.String("agent_execution_id", agentExecutionID)) + return nil +} + +// runAgentSimulation simulates the agent execution lifecycle +func (s *SimulatedAgentManagerClient) runAgentSimulation(instance *simulatedInstance, req *executor.LaunchAgentRequest) { + // Wait for launch delay + select { + case <-time.After(s.launchDelay): + case <-instance.stopCh: + return + case <-s.stopCh: + return + } + + // Publish agent started event + s.publishAgentEvent(events.AgentStarted, instance) + + // Simulate some ACP messages + s.publishACPMessages(instance, req) + + // Wait for execution time + select { + case <-time.After(s.executionTime): + case <-instance.stopCh: + s.publishAgentEvent(events.AgentStopped, instance) + return + case <-s.stopCh: + return + } + + // Publish agent ready (finished prompt, waiting for follow-up or completion) + instance.statusMu.Lock() + instance.status = v1.AgentStatusReady + instance.statusMu.Unlock() + s.publishAgentEvent(events.AgentReady, instance) +} + +// publishAgentEvent publishes an agent lifecycle event +func (s *SimulatedAgentManagerClient) publishAgentEvent(eventType string, instance *simulatedInstance) { + instance.statusMu.Lock() + status := instance.status + instance.statusMu.Unlock() + + data := map[string]interface{}{ + "instance_id": instance.id, + "task_id": instance.taskID, + "agent_profile_id": instance.agentProfileID, + "container_id": "sim-container-" + instance.id[:8], + "status": string(status), + "started_at": time.Now(), + "progress": 50, + } + + event := bus.NewEvent(eventType, "simulated-agent-manager", data) + if err := s.eventBus.Publish(context.Background(), eventType, event); err != nil { + s.logger.Error("failed to publish agent event") + } +} + +// publishACPMessages publishes simulated ACP messages +func (s *SimulatedAgentManagerClient) publishACPMessages(instance *simulatedInstance, req *executor.LaunchAgentRequest) { + var messages []protocol.Message + + if s.acpMessageFn != nil { + messages = s.acpMessageFn(instance.taskID, instance.id) + } else { + // Default ACP messages + messages = []protocol.Message{ + { + Type: protocol.MessageTypeProgress, + TaskID: instance.taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "progress": 25, + "stage": "starting", + "message": "Agent started processing task", + }, + }, + { + Type: protocol.MessageTypeLog, + TaskID: instance.taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "level": "info", + "message": "Processing task: " + req.TaskDescription, + }, + }, + { + Type: protocol.MessageTypeProgress, + TaskID: instance.taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "progress": 75, + "stage": "executing", + "message": "Task execution in progress", + }, + }, + } + } + + // Publish each message with a small delay + for _, msg := range messages { + sessionID := msg.SessionID + if sessionID == "" { + sessionID = instance.sessionID + } + msgData := map[string]interface{}{ + "type": msg.Type, + "task_id": msg.TaskID, + "session_id": sessionID, + "timestamp": msg.Timestamp, + "data": msg.Data, + } + + event := bus.NewEvent(events.AgentStream, "simulated-agent", msgData) + subject := events.BuildAgentStreamSubject(instance.taskID) + if err := s.eventBus.Publish(context.Background(), subject, event); err != nil { + s.logger.Error("failed to publish agent stream event") + } + + time.Sleep(20 * time.Millisecond) + } +} + +// StopAgent simulates stopping an agent +func (s *SimulatedAgentManagerClient) StopAgent(ctx context.Context, agentExecutionID string, force bool) error { + s.mu.Lock() + execution, exists := s.instances[agentExecutionID] + s.mu.Unlock() + + if !exists { + return fmt.Errorf("agent execution %q not found", agentExecutionID) + } + + close(execution.stopCh) + execution.statusMu.Lock() + execution.status = v1.AgentStatusStopped + execution.statusMu.Unlock() + + s.publishAgentEvent(events.AgentStopped, execution) + return nil +} + +// PromptAgent sends a follow-up prompt to a running agent +// Note: attachments parameter is accepted but not used in simulation +func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentExecutionID string, prompt string, _ []v1.MessageAttachment) (*executor.PromptResult, error) { + s.mu.Lock() + execution, exists := s.instances[agentExecutionID] + s.mu.Unlock() + + if !exists { + return nil, fmt.Errorf("agent execution %q not found", agentExecutionID) + } + + // Simulate receiving prompt and generating response + go func() { + time.Sleep(50 * time.Millisecond) + + msg := protocol.Message{ + Type: protocol.MessageTypeLog, + TaskID: execution.taskID, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "level": "info", + "message": "Received follow-up prompt: " + prompt, + }, + } + + msgData := map[string]interface{}{ + "type": msg.Type, + "task_id": msg.TaskID, + "timestamp": msg.Timestamp, + "data": msg.Data, + } + + event := bus.NewEvent(events.AgentStream, "simulated-agent", msgData) + subject := events.BuildAgentStreamSubject(execution.taskID) + if err := s.eventBus.Publish(context.Background(), subject, event); err != nil { + s.logger.Warn("failed to publish simulated agent stream event", zap.Error(err)) + } + }() + + return &executor.PromptResult{ + StopReason: "end_turn", + }, nil +} + +// RespondToPermissionBySessionID responds to a permission request for a session +func (s *SimulatedAgentManagerClient) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { + s.logger.Info("simulated: responding to permission", + zap.String("session_id", sessionID), + zap.String("pending_id", pendingID), + zap.String("option_id", optionID), + zap.Bool("cancelled", cancelled)) + return nil +} + +// CompleteAgent marks an agent as completed +func (s *SimulatedAgentManagerClient) CompleteAgent(executionID string) { + s.mu.Lock() + execution, exists := s.instances[executionID] + s.mu.Unlock() + + if !exists { + return + } + + execution.status = v1.AgentStatusCompleted + s.publishAgentEvent(events.AgentCompleted, execution) +} + +// FailAgent marks an agent as failed +func (s *SimulatedAgentManagerClient) FailAgent(executionID string, reason string) { + s.mu.Lock() + execution, exists := s.instances[executionID] + s.mu.Unlock() + + if !exists { + return + } + + execution.status = v1.AgentStatusFailed + s.publishAgentEvent(events.AgentFailed, execution) +} + +// GetLaunchCount returns the number of times LaunchAgent was called +func (s *SimulatedAgentManagerClient) GetLaunchCount() int { + return int(atomic.LoadInt32(&s.launchCount)) +} + +// Close stops all simulated agents +func (s *SimulatedAgentManagerClient) Close() { + close(s.stopCh) +} + +// IsAgentRunningForSession checks if a simulated agent is running for a session +func (s *SimulatedAgentManagerClient) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + for _, inst := range s.instances { + if inst.sessionID == sessionID && inst.status == v1.AgentStatusRunning { + return true + } + } + return false +} + +// CancelAgent cancels the current agent turn for a session +func (s *SimulatedAgentManagerClient) CancelAgent(ctx context.Context, sessionID string) error { + s.logger.Info("simulated: cancelling agent turn", + zap.String("session_id", sessionID)) + return nil +} + +// ResolveAgentProfile resolves an agent profile ID to profile information +func (s *SimulatedAgentManagerClient) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) { + return &executor.AgentProfileInfo{ + ProfileID: profileID, + ProfileName: "Simulated Profile", + AgentID: "augment-agent", + AgentName: "Augment Agent", + Model: "claude-sonnet-4-20250514", + }, nil +} + +func (s *SimulatedAgentManagerClient) IsPassthroughSession(ctx context.Context, sessionID string) bool { + return false +} diff --git a/apps/backend/internal/integration/task_integration_test.go b/apps/backend/internal/integration/task_integration_test.go new file mode 100644 index 0000000000..b6089b7c81 --- /dev/null +++ b/apps/backend/internal/integration/task_integration_test.go @@ -0,0 +1,324 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestTaskCRUD(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) + + // Create workflow (workflow steps are created automatically) + workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Task Test Workflow", + "workflow_template_id": "simple", + }) + require.NoError(t, err) + + var workflowPayload map[string]interface{} + err = workflowResp.ParsePayload(&workflowPayload) + require.NoError(t, err) + workflowID := workflowPayload["id"].(string) + + // Get first workflow step + stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var stepListPayload map[string]interface{} + err = stepResp.ParsePayload(&stepListPayload) + require.NoError(t, err) + steps := stepListPayload["steps"].([]interface{}) + require.NotEmpty(t, steps) + workflowStepID := steps[0].(map[string]interface{})["id"].(string) + + var taskID string + + // Create task + t.Run("CreateTask", func(t *testing.T) { + resp, err := client.SendRequest("task-create-1", ws.ActionTaskCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": "Test Task", + "description": "A test task for integration testing", + "priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH) + "repository_id": repositoryID, + "base_branch": "main", + }) + require.NoError(t, err) + + // Debug: print error if there is one + if resp.Type == ws.MessageTypeError { + var errPayload ws.ErrorPayload + if err := resp.ParsePayload(&errPayload); err != nil { + t.Fatalf("failed to parse error payload: %v", err) + } + t.Logf("Error response: %+v", errPayload) + } + + require.Equal(t, ws.MessageTypeResponse, resp.Type, "Expected response but got %s", resp.Type) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + taskID = payload["id"].(string) + assert.NotEmpty(t, taskID) + assert.Equal(t, "Test Task", payload["title"]) + assert.Equal(t, "CREATED", payload["state"]) + }) + + // Get task + t.Run("GetTask", func(t *testing.T) { + resp, err := client.SendRequest("task-get-1", ws.ActionTaskGet, map[string]interface{}{ + "id": taskID, + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.Equal(t, taskID, payload["id"]) + assert.Equal(t, "Test Task", payload["title"]) + }) + + // Update task + t.Run("UpdateTask", func(t *testing.T) { + resp, err := client.SendRequest("task-update-1", ws.ActionTaskUpdate, map[string]interface{}{ + "id": taskID, + "title": "Updated Task Title", + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.Equal(t, "Updated Task Title", payload["title"]) + }) + + // List tasks + t.Run("ListTasks", func(t *testing.T) { + resp, err := client.SendRequest("task-list-1", ws.ActionTaskList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + tasks, ok := payload["tasks"].([]interface{}) + require.True(t, ok) + assert.Len(t, tasks, 1) + }) + + // Delete task + t.Run("DeleteTask", func(t *testing.T) { + resp, err := client.SendRequest("task-delete-1", ws.ActionTaskDelete, map[string]interface{}{ + "id": taskID, + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.Equal(t, true, payload["success"]) + + // Verify task is deleted + listResp, err := client.SendRequest("task-list-2", ws.ActionTaskList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var listPayload map[string]interface{} + err = listResp.ParsePayload(&listPayload) + require.NoError(t, err) + + tasks, ok := listPayload["tasks"].([]interface{}) + require.True(t, ok) + assert.Len(t, tasks, 0) + }) +} + +func TestTaskStateTransitions(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) + + // Create workflow and task (workflow steps are created automatically) + workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "State Test Workflow", + "workflow_template_id": "simple", + }) + var workflowPayload map[string]interface{} + if err := workflowResp.ParsePayload(&workflowPayload); err != nil { + t.Fatalf("failed to parse workflow payload: %v", err) + } + workflowID := workflowPayload["id"].(string) + + // Get first workflow step + stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + var stepListPayload map[string]interface{} + if err := stepResp.ParsePayload(&stepListPayload); err != nil { + t.Fatalf("failed to parse step list payload: %v", err) + } + steps := stepListPayload["steps"].([]interface{}) + workflowStepID := steps[0].(map[string]interface{})["id"].(string) + + taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": "State Test Task", + "description": "Test state transitions", + "repository_id": repositoryID, + "base_branch": "main", + }) + var taskPayload map[string]interface{} + if err := taskResp.ParsePayload(&taskPayload); err != nil { + t.Fatalf("failed to parse task payload: %v", err) + } + taskID := taskPayload["id"].(string) + + // Test state transitions + stateTests := []struct { + name string + newState string + }{ + {"ToInProgress", "IN_PROGRESS"}, + {"ToCompleted", "COMPLETED"}, + {"BackToTodo", "TODO"}, + {"ToBlocked", "BLOCKED"}, + } + + for i, tc := range stateTests { + t.Run(tc.name, func(t *testing.T) { + resp, err := client.SendRequest( + "state-"+string(rune('0'+i)), + ws.ActionTaskState, + map[string]interface{}{ + "id": taskID, + "state": tc.newState, + }, + ) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.Equal(t, tc.newState, payload["state"]) + }) + } +} + +func TestTaskMove(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) + + // Create workflow (workflow steps are created automatically) + workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Move Test Workflow", + "workflow_template_id": "simple", + }) + var workflowPayload map[string]interface{} + if err := workflowResp.ParsePayload(&workflowPayload); err != nil { + t.Fatalf("failed to parse workflow payload: %v", err) + } + workflowID := workflowPayload["id"].(string) + + // Get workflow steps (at least 2 should exist from default template) + stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + var stepListPayload map[string]interface{} + if err := stepResp.ParsePayload(&stepListPayload); err != nil { + t.Fatalf("failed to parse step list payload: %v", err) + } + steps := stepListPayload["steps"].([]interface{}) + require.GreaterOrEqual(t, len(steps), 2, "Expected at least 2 workflow steps") + step1ID := steps[0].(map[string]interface{})["id"].(string) + step2ID := steps[1].(map[string]interface{})["id"].(string) + + // Create task in first step + taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": step1ID, + "title": "Movable Task", + "repository_id": repositoryID, + "base_branch": "main", + }) + var taskPayload map[string]interface{} + if err := taskResp.ParsePayload(&taskPayload); err != nil { + t.Fatalf("failed to parse task payload: %v", err) + } + taskID := taskPayload["id"].(string) + + // Move task to second step + t.Run("MoveToStep2", func(t *testing.T) { + resp, err := client.SendRequest("move-1", ws.ActionTaskMove, map[string]interface{}{ + "id": taskID, + "workflow_id": workflowID, + "workflow_step_id": step2ID, + "position": 0, + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + // Response is {"task": {...}, "workflow_step": {...}} + taskData := payload["task"].(map[string]interface{}) + assert.Equal(t, step2ID, taskData["workflow_step_id"]) + }) + + // Verify task is now in step 2 + t.Run("VerifyMove", func(t *testing.T) { + resp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]interface{}{ + "id": taskID, + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.Equal(t, step2ID, payload["workflow_step_id"]) + }) +} diff --git a/apps/backend/internal/integration/test_server_test.go b/apps/backend/internal/integration/test_server_test.go new file mode 100644 index 0000000000..665257c69e --- /dev/null +++ b/apps/backend/internal/integration/test_server_test.go @@ -0,0 +1,402 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "context" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events/bus" + gateways "github.com/kandev/kandev/internal/gateway/websocket" + "github.com/kandev/kandev/internal/orchestrator" + orchestratorhandlers "github.com/kandev/kandev/internal/orchestrator/handlers" + taskcontroller "github.com/kandev/kandev/internal/task/controller" + taskhandlers "github.com/kandev/kandev/internal/task/handlers" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/workflow" + workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" + workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers" + workflowservice "github.com/kandev/kandev/internal/workflow/service" + "github.com/kandev/kandev/internal/worktree" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// OrchestratorTestServer extends TestServer with orchestrator components +type OrchestratorTestServer struct { + Server *httptest.Server + Gateway *gateways.Gateway + TaskRepo repository.Repository + TaskSvc *taskservice.Service + WorkflowSvc *workflowservice.Service + EventBus bus.EventBus + OrchestratorSvc *orchestrator.Service + AgentManager *SimulatedAgentManagerClient + Logger *logger.Logger + ctx context.Context + cancelFunc context.CancelFunc +} + +// taskRepositoryAdapter adapts the task repository for the orchestrator +type taskRepositoryAdapter struct { + repo repository.Repository + svc *taskservice.Service +} + +func (a *taskRepositoryAdapter) GetTask(ctx context.Context, taskID string) (*v1.Task, error) { + task, err := a.repo.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + return task.ToAPI(), nil +} + +func (a *taskRepositoryAdapter) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { + _, err := a.svc.UpdateTaskState(ctx, taskID, state) + return err +} + +// testMessageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface for tests +type testMessageCreatorAdapter struct { + svc *taskservice.Service +} + +func (a *testMessageCreatorAdapter) CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error { + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: agentSessionID, + TaskID: taskID, + TurnID: turnID, + Content: content, + AuthorType: "agent", + }) + return err +} + +func (a *testMessageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string, metadata map[string]interface{}) error { + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: agentSessionID, + TaskID: taskID, + TurnID: turnID, + Content: content, + AuthorType: "user", + Metadata: metadata, + }) + return err +} + +func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { + metadata := map[string]interface{}{ + "tool_call_id": toolCallID, + "title": title, + "status": status, + } + if parentToolCallID != "" { + metadata["parent_tool_call_id"] = parentToolCallID + } + if normalized != nil { + metadata["normalized"] = normalized + } + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: agentSessionID, + TaskID: taskID, + TurnID: turnID, + Content: title, + AuthorType: "agent", + Type: "tool_call", + Metadata: metadata, + }) + return err +} + +func (a *testMessageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error { + return a.svc.UpdateToolCallMessageWithCreate(ctx, agentSessionID, toolCallID, parentToolCallID, status, result, title, normalized, taskID, turnID, msgType) +} + +func (a *testMessageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error { + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: agentSessionID, + TaskID: taskID, + TurnID: turnID, + Content: content, + AuthorType: "agent", + Type: messageType, + Metadata: metadata, + RequestsInput: requestsInput, + }) + return err +} + +func (a *testMessageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) { + metadata := map[string]interface{}{ + "pending_id": pendingID, + "tool_call_id": toolCallID, + "options": options, + "action_type": actionType, + "action_details": actionDetails, + } + msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + TurnID: turnID, + Content: title, + AuthorType: "agent", + Type: "permission_request", + Metadata: metadata, + }) + if err != nil { + return "", err + } + return msg.ID, nil +} + +func (a *testMessageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error { + return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status) +} + +func (a *testMessageCreatorAdapter) CreateAgentMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error { + _, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{ + TaskSessionID: agentSessionID, + TaskID: taskID, + TurnID: turnID, + Content: content, + AuthorType: "agent", + Type: "content", + }) + return err +} + +func (a *testMessageCreatorAdapter) AppendAgentMessage(ctx context.Context, messageID, additionalContent string) error { + return a.svc.AppendMessageContent(ctx, messageID, additionalContent) +} + +func (a *testMessageCreatorAdapter) CreateThinkingMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error { + _, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{ + TaskSessionID: agentSessionID, + TaskID: taskID, + TurnID: turnID, + Content: "", + AuthorType: "agent", + Type: "thinking", + Metadata: map[string]interface{}{ + "thinking": content, + }, + }) + return err +} + +func (a *testMessageCreatorAdapter) AppendThinkingMessage(ctx context.Context, messageID, additionalContent string) error { + return a.svc.AppendThinkingContent(ctx, messageID, additionalContent) +} + +// testTurnServiceAdapter adapts the task service to the orchestrator.TurnService interface for tests +type testTurnServiceAdapter struct { + svc *taskservice.Service +} + +func (a *testTurnServiceAdapter) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { + return a.svc.StartTurn(ctx, sessionID) +} + +func (a *testTurnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) error { + return a.svc.CompleteTurn(ctx, turnID) +} + +func (a *testTurnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) { + return a.svc.GetActiveTurn(ctx, sessionID) +} + +// NewOrchestratorTestServer creates a test server with full orchestrator support +func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { + t.Helper() + + // Initialize logger (quiet for tests) + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "console", + }) + require.NoError(t, err) + + // Create context + ctx, cancel := context.WithCancel(context.Background()) + + // Initialize event bus + eventBus := bus.NewMemoryEventBus(log) + + tmpDir := t.TempDir() + dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) + require.NoError(t, err) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + taskRepoImpl, cleanup, err := repository.Provide(sqlxDB) + require.NoError(t, err) + taskRepo := taskRepoImpl + t.Cleanup(func() { + if err := sqlxDB.Close(); err != nil { + t.Errorf("failed to close sqlite db: %v", err) + } + if cleanup != nil { + if err := cleanup(); err != nil { + t.Errorf("failed to close task repo: %v", err) + } + } + }) + if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { + t.Fatalf("failed to init worktree store: %v", err) + } + + // Initialize workflow service + _, workflowSvc, _, err := workflow.Provide(sqlxDB, log) + require.NoError(t, err) + + // Initialize task service and wire workflow step creator + taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) + taskSvc.SetWorkflowStepCreator(workflowSvc) + + // Create simulated agent manager + agentManager := NewSimulatedAgentManager(eventBus, log) + + // Create task repository adapter + taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc} + + // Create orchestrator service + cfg := orchestrator.DefaultServiceConfig() + cfg.Scheduler.ProcessInterval = 50 * time.Millisecond // Faster for tests + orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, nil, log) + + // Wire message creator for message persistence (similar to cmd/kandev/orchestrator.go) + msgCreator := &testMessageCreatorAdapter{svc: taskSvc} + orchestratorSvc.SetMessageCreator(msgCreator) + orchestratorSvc.SetTurnService(&testTurnServiceAdapter{svc: taskSvc}) + + // Create WebSocket gateway + gateway := gateways.NewGateway(log) + + // Register orchestrator handlers (Pattern A) + orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log) + orchestratorHandlers.RegisterHandlers(gateway.Dispatcher) + + // Start hub + go gateway.Hub.Run(ctx) + + // Register task notifications to broadcast events to WebSocket clients + gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) + + // Start orchestrator + require.NoError(t, orchestratorSvc.Start(ctx)) + + // Create router + gin.SetMode(gin.TestMode) + router := gin.New() + gateway.SetupRoutes(router) + + // Register handlers (HTTP + WS) + workspaceController := taskcontroller.NewWorkspaceController(taskSvc) + taskWorkflowController := taskcontroller.NewWorkflowController(taskSvc) + taskWorkflowController.SetWorkflowStepLister(workflowSvc) + taskController := taskcontroller.NewTaskController(taskSvc) + executorController := taskcontroller.NewExecutorController(taskSvc) + environmentController := taskcontroller.NewEnvironmentController(taskSvc) + repositoryController := taskcontroller.NewRepositoryController(taskSvc) + workflowCtrl := workflowcontroller.NewController(workflowSvc) + taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) + taskhandlers.RegisterWorkflowRoutes(router, gateway.Dispatcher, taskWorkflowController, log) + planService := taskservice.NewPlanService(taskRepo, eventBus, log) + taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, taskRepo, planService, log) + taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) + taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) + taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) + workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log) + + // Create test server + server := httptest.NewServer(router) + + return &OrchestratorTestServer{ + Server: server, + Gateway: gateway, + TaskRepo: taskRepo, + TaskSvc: taskSvc, + WorkflowSvc: workflowSvc, + EventBus: eventBus, + OrchestratorSvc: orchestratorSvc, + AgentManager: agentManager, + Logger: log, + ctx: ctx, + cancelFunc: cancel, + } +} + +// Close shuts down the test server +func (ts *OrchestratorTestServer) Close() { + if err := ts.OrchestratorSvc.Stop(); err != nil { + ts.Logger.Warn("failed to stop orchestrator", zap.Error(err)) + } + ts.AgentManager.Close() + ts.cancelFunc() + ts.Server.Close() + if err := ts.TaskRepo.Close(); err != nil { + ts.Logger.Warn("failed to close task repo", zap.Error(err)) + } + ts.EventBus.Close() +} + +// CreateTestTask creates a task for testing. +func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID string, priority int) string { + t.Helper() + + workspace, err := ts.TaskSvc.CreateWorkspace(context.Background(), &taskservice.CreateWorkspaceRequest{ + Name: "Test Workspace", + }) + require.NoError(t, err) + + // Create workflow first (workflow steps are created automatically via default template) + defaultTemplateID := "simple" + wf, err := ts.TaskSvc.CreateWorkflow(context.Background(), &taskservice.CreateWorkflowRequest{ + WorkspaceID: workspace.ID, + Name: "Test Workflow", + Description: "Test workflow for orchestrator", + WorkflowTemplateID: &defaultTemplateID, + }) + require.NoError(t, err) + + // Get first workflow step from workflow + steps, err := ts.WorkflowSvc.ListStepsByWorkflow(context.Background(), wf.ID) + require.NoError(t, err) + require.NotEmpty(t, steps, "workflow should have workflow steps") + workflowStepID := steps[0].ID + + // Create task with agent profile ID + repository, err := ts.TaskSvc.CreateRepository(context.Background(), &taskservice.CreateRepositoryRequest{ + WorkspaceID: workspace.ID, + Name: "Test Repo", + LocalPath: createTempRepoDir(t), + }) + require.NoError(t, err) + + task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{ + WorkspaceID: workspace.ID, + WorkflowID: wf.ID, + WorkflowStepID: workflowStepID, + Title: "Test Task", + Description: "This is a test task for the orchestrator", + Priority: priority, + Repositories: []taskservice.TaskRepositoryInput{ + { + RepositoryID: repository.ID, + BaseBranch: "main", + }, + }, + }) + require.NoError(t, err) + + return task.ID +} diff --git a/apps/backend/internal/integration/workflow_integration_test.go b/apps/backend/internal/integration/workflow_integration_test.go new file mode 100644 index 0000000000..59d21e0e59 --- /dev/null +++ b/apps/backend/internal/integration/workflow_integration_test.go @@ -0,0 +1,101 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestWorkflowCRUD(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + workspaceID := createWorkspace(t, client) + + // Create workflow + t.Run("CreateWorkflow", func(t *testing.T) { + resp, err := client.SendRequest("workflow-create-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Test Workflow", + "description": "A test workflow for integration testing", + "workflow_template_id": "simple", + }) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + assert.NotEmpty(t, payload["id"]) + assert.Equal(t, "Test Workflow", payload["name"]) + }) + + // List workflows + t.Run("ListWorkflows", func(t *testing.T) { + resp, err := client.SendRequest("workflow-list-1", ws.ActionWorkflowList, map[string]interface{}{ + "workspace_id": workspaceID, + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + workflows, ok := payload["workflows"].([]interface{}) + require.True(t, ok) + assert.Len(t, workflows, 1) + }) +} + +func TestWorkflowStepList(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + workspaceID := createWorkspace(t, client) + + // Create a workflow (workflow steps are created automatically with default template) + workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Workflow Step Test Workflow", + "workflow_template_id": "simple", + }) + require.NoError(t, err) + + var workflowPayload map[string]interface{} + err = workflowResp.ParsePayload(&workflowPayload) + require.NoError(t, err) + workflowID := workflowPayload["id"].(string) + + // List workflow steps + t.Run("ListWorkflowSteps", func(t *testing.T) { + resp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + steps, ok := payload["steps"].([]interface{}) + require.True(t, ok) + // Default workflow template creates 4 steps: Todo, In Progress, Review, Done + assert.GreaterOrEqual(t, len(steps), 1) + + // Verify first step has expected fields + firstStep := steps[0].(map[string]interface{}) + assert.NotEmpty(t, firstStep["id"]) + assert.NotEmpty(t, firstStep["name"]) + }) +} diff --git a/apps/backend/internal/integration/ws_client_test.go b/apps/backend/internal/integration/ws_client_test.go new file mode 100644 index 0000000000..e739eeede5 --- /dev/null +++ b/apps/backend/internal/integration/ws_client_test.go @@ -0,0 +1,198 @@ +// Package integration provides end-to-end integration tests for the Kandev backend. +package integration + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +// OrchestratorWSClient is a WebSocket client for orchestrator tests +type OrchestratorWSClient struct { + conn *websocket.Conn + t *testing.T + notifications chan *ws.Message + done chan struct{} + // pending tracks in-flight requests: request ID -> response channel + pending map[string]chan *ws.Message + // send is the channel for outgoing messages (serialized through writePump) + send chan []byte + mu sync.Mutex +} + +// NewOrchestratorWSClient creates a WebSocket connection to the test server +func NewOrchestratorWSClient(t *testing.T, serverURL string) *OrchestratorWSClient { + t.Helper() + + wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + "/ws" + + dialer := websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + } + + conn, resp, err := dialer.Dial(wsURL, nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + + client := &OrchestratorWSClient{ + conn: conn, + t: t, + notifications: make(chan *ws.Message, 100), + done: make(chan struct{}), + pending: make(map[string]chan *ws.Message), + send: make(chan []byte, 256), + } + + go client.readPump() + go client.writePump() + + return client +} + +func createOrchestratorWorkspace(t *testing.T, client *OrchestratorWSClient) string { + t.Helper() + + resp, err := client.SendRequest("workspace-1", ws.ActionWorkspaceCreate, map[string]interface{}{ + "name": "Test Workspace", + }) + require.NoError(t, err) + + var payload map[string]interface{} + require.NoError(t, resp.ParsePayload(&payload)) + + return payload["id"].(string) +} + +// readPump reads messages from the WebSocket connection +func (c *OrchestratorWSClient) readPump() { + defer close(c.done) + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + return + } + + var msg ws.Message + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + + if msg.Type == ws.MessageTypeNotification { + select { + case c.notifications <- &msg: + default: + } + } else { + // Route response to the pending request by ID + c.mu.Lock() + ch, ok := c.pending[msg.ID] + c.mu.Unlock() + if ok { + select { + case ch <- &msg: + default: + } + } + } + } +} + +// writePump serializes all writes to the WebSocket connection +func (c *OrchestratorWSClient) writePump() { + for data := range c.send { + if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return + } + } +} + +// Close closes the WebSocket connection +func (c *OrchestratorWSClient) Close() { + close(c.send) + if err := c.conn.Close(); err != nil { + c.t.Logf("failed to close websocket: %v", err) + } + <-c.done +} + +// SendRequest sends a request and waits for a response +func (c *OrchestratorWSClient) SendRequest(id, action string, payload interface{}) (*ws.Message, error) { + msg, err := ws.NewRequest(id, action, payload) + if err != nil { + return nil, err + } + + data, err := json.Marshal(msg) + if err != nil { + return nil, err + } + + // Create a response channel for this request + respCh := make(chan *ws.Message, 1) + + // Register the pending request BEFORE sending (so we don't miss the response) + c.mu.Lock() + c.pending[id] = respCh + c.mu.Unlock() + + // Ensure we clean up when done + defer func() { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + }() + + // Send the request through the write pump (serialized) + select { + case c.send <- data: + case <-time.After(5 * time.Second): + return nil, fmt.Errorf("send buffer full") + } + + // Wait for response on our dedicated channel + select { + case resp := <-respCh: + return resp, nil + case <-time.After(30 * time.Second): + return nil, context.DeadlineExceeded + } +} + +// WaitForNotification waits for a notification with the given action prefix +func (c *OrchestratorWSClient) WaitForNotification(actionPrefix string, timeout time.Duration) (*ws.Message, error) { + deadline := time.After(timeout) + for { + select { + case msg := <-c.notifications: + if strings.HasPrefix(msg.Action, actionPrefix) { + return msg, nil + } + case <-deadline: + return nil, context.DeadlineExceeded + } + } +} + +// CollectNotifications collects all notifications for a duration +func (c *OrchestratorWSClient) CollectNotifications(duration time.Duration) []*ws.Message { + var msgs []*ws.Message + deadline := time.After(duration) + for { + select { + case msg := <-c.notifications: + msgs = append(msgs, msg) + case <-deadline: + return msgs + } + } +} diff --git a/apps/backend/internal/lsp/installer/github_release_strategy.go b/apps/backend/internal/lsp/installer/github_release_strategy.go index 457625fe69..0bb50a2367 100644 --- a/apps/backend/internal/lsp/installer/github_release_strategy.go +++ b/apps/backend/internal/lsp/installer/github_release_strategy.go @@ -86,32 +86,14 @@ func (s *GithubReleaseStrategy) Install(ctx context.Context) (*InstallResult, er outPath := filepath.Join(s.binDir, s.binary) - // Decompress .gz + // Decompress .gz or write directly if strings.HasSuffix(asset, ".gz") { - gzReader, err := gzip.NewReader(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to create gzip reader: %w", err) - } - defer func() { _ = gzReader.Close() }() - - outFile, err := os.Create(outPath) - if err != nil { - return nil, fmt.Errorf("failed to create output file: %w", err) - } - defer func() { _ = outFile.Close() }() - - if _, err := io.Copy(outFile, gzReader); err != nil { - return nil, fmt.Errorf("failed to decompress: %w", err) + if err := writeGzipBody(resp.Body, outPath); err != nil { + return nil, err } } else { - outFile, err := os.Create(outPath) - if err != nil { - return nil, fmt.Errorf("failed to create output file: %w", err) - } - defer func() { _ = outFile.Close() }() - - if _, err := io.Copy(outFile, resp.Body); err != nil { - return nil, fmt.Errorf("failed to write binary: %w", err) + if err := writeBody(resp.Body, outPath); err != nil { + return nil, err } } @@ -125,3 +107,37 @@ func (s *GithubReleaseStrategy) Install(ctx context.Context) (*InstallResult, er BinaryPath: outPath, }, nil } + +// writeGzipBody decompresses a gzip reader into outPath. +func writeGzipBody(body io.Reader, outPath string) error { + gzReader, err := gzip.NewReader(body) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer func() { _ = gzReader.Close() }() + + outFile, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer func() { _ = outFile.Close() }() + + if _, err := io.Copy(outFile, gzReader); err != nil { + return fmt.Errorf("failed to decompress: %w", err) + } + return nil +} + +// writeBody writes a plain reader into outPath. +func writeBody(body io.Reader, outPath string) error { + outFile, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer func() { _ = outFile.Close() }() + + if _, err := io.Copy(outFile, body); err != nil { + return fmt.Errorf("failed to write binary: %w", err) + } + return nil +} diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index f630eb9067..e9662f50c4 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -120,67 +120,58 @@ func (h *Handlers) handleListWorkspaces(ctx context.Context, msg *ws.Message) (* return ws.NewResponse(msg.ID, msg.Action, resp) } -// handleListWorkflows lists workflows for a workspace. -func (h *Handlers) handleListWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - WorkspaceID string `json:"workspace_id"` +// unmarshalStringField unmarshals a JSON payload and returns the value of a single string field. +func unmarshalStringField(payload json.RawMessage, fieldName string) (string, error) { + var m map[string]string + if err := json.Unmarshal(payload, &m); err != nil { + return "", err } - if err := json.Unmarshal(msg.Payload, &req); err != nil { + return m[fieldName], nil +} + +// handleListByField is a generic handler for listing resources identified by a single string field. +func (h *Handlers) handleListByField( + ctx context.Context, msg *ws.Message, + fieldName, logErrMsg, clientErrMsg string, + fn func(context.Context, string) (any, error), +) (*ws.Message, error) { + value, err := unmarshalStringField(msg.Payload, fieldName) + if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.WorkspaceID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) + if value == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, fieldName+" is required", nil) } - - resp, err := h.taskWorkflowCtrl.ListWorkflows(ctx, dto.ListWorkflowsRequest{WorkspaceID: req.WorkspaceID}) + resp, err := fn(ctx, value) if err != nil { - h.logger.Error("failed to list workflows", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workflows", nil) + h.logger.Error(logErrMsg, zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, clientErrMsg, nil) } - return ws.NewResponse(msg.ID, msg.Action, resp) } +// handleListWorkflows lists workflows for a workspace. +func (h *Handlers) handleListWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return h.handleListByField(ctx, msg, "workspace_id", "failed to list workflows", "Failed to list workflows", + func(ctx context.Context, workspaceID string) (any, error) { + return h.taskWorkflowCtrl.ListWorkflows(ctx, dto.ListWorkflowsRequest{WorkspaceID: workspaceID}) + }) +} + // handleListWorkflowSteps lists workflow steps for a workflow. func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - WorkflowID string `json:"workflow_id"` - } - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.WorkflowID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) - } - - resp, err := h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: req.WorkflowID}) - if err != nil { - h.logger.Error("failed to list workflow steps", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workflow steps", nil) - } - - return ws.NewResponse(msg.ID, msg.Action, resp) + return h.handleListByField(ctx, msg, "workflow_id", "failed to list workflow steps", "Failed to list workflow steps", + func(ctx context.Context, workflowID string) (any, error) { + return h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: workflowID}) + }) } // handleListTasks lists tasks for a workflow. func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - WorkflowID string `json:"workflow_id"` - } - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.WorkflowID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) - } - - resp, err := h.taskCtrl.ListTasks(ctx, dto.ListTasksRequest{WorkflowID: req.WorkflowID}) - if err != nil { - h.logger.Error("failed to list tasks", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil) - } - - return ws.NewResponse(msg.ID, msg.Action, resp) + return h.handleListByField(ctx, msg, "workflow_id", "failed to list tasks", "Failed to list tasks", + func(ctx context.Context, workflowID string) (any, error) { + return h.taskCtrl.ListTasks(ctx, dto.ListTasksRequest{WorkflowID: workflowID}) + }) } // handleCreateTask creates a new task. diff --git a/apps/backend/internal/notifications/providers/system_provider.go b/apps/backend/internal/notifications/providers/system_provider.go index a341c6629a..80b0b5823c 100644 --- a/apps/backend/internal/notifications/providers/system_provider.go +++ b/apps/backend/internal/notifications/providers/system_provider.go @@ -11,6 +11,13 @@ import ( "time" ) + +// OS name constants used for runtime.GOOS comparisons. +const ( + osDarwin = "darwin" + osLinux = "linux" + osWindows = "windows" +) type SystemProvider struct { assets systemAssets } @@ -21,12 +28,12 @@ func NewSystemProvider() *SystemProvider { func (p *SystemProvider) Available() bool { switch runtime.GOOS { - case "darwin": + case osDarwin: return true - case "windows": + case osWindows: _, err := exec.LookPath("powershell.exe") return err == nil - case "linux": + case osLinux: if isWSL() { _, err := exec.LookPath("powershell.exe") return err == nil @@ -81,55 +88,8 @@ func parseSystemConfig(raw map[string]interface{}) (systemConfig, error) { if raw == nil { return cfg, nil } - if value, ok := raw["sound_enabled"]; ok { - enabled, ok := value.(bool) - if !ok { - return cfg, fmt.Errorf("sound_enabled must be a boolean") - } - cfg.SoundEnabled = enabled - } - if value, ok := raw["sound_file"]; ok { - text, ok := value.(string) - if !ok { - return cfg, fmt.Errorf("sound_file must be a string") - } - cfg.SoundFile = strings.TrimSpace(text) - } - if value, ok := raw["app_name"]; ok { - text, ok := value.(string) - if !ok { - return cfg, fmt.Errorf("app_name must be a string") - } - if strings.TrimSpace(text) != "" { - cfg.AppName = strings.TrimSpace(text) - } - } - if value, ok := raw["icon_path"]; ok { - text, ok := value.(string) - if !ok { - return cfg, fmt.Errorf("icon_path must be a string") - } - cfg.IconPath = strings.TrimSpace(text) - } - if value, ok := raw["timeout_ms"]; ok { - switch v := value.(type) { - case float64: - cfg.TimeoutMS = int(v) - case int: - cfg.TimeoutMS = v - case int64: - cfg.TimeoutMS = int(v) - case string: - if strings.TrimSpace(v) != "" { - parsed, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil { - return cfg, fmt.Errorf("timeout_ms must be a number") - } - cfg.TimeoutMS = parsed - } - default: - return cfg, fmt.Errorf("timeout_ms must be a number") - } + if err := applySystemConfigFields(&cfg, raw); err != nil { + return cfg, err } if cfg.TimeoutMS <= 0 { cfg.TimeoutMS = 10000 @@ -137,16 +97,112 @@ func parseSystemConfig(raw map[string]interface{}) (systemConfig, error) { return cfg, nil } +func applySystemConfigFields(cfg *systemConfig, raw map[string]interface{}) error { + if err := parseSoundEnabled(cfg, raw); err != nil { + return err + } + if err := parseSoundFile(cfg, raw); err != nil { + return err + } + if err := parseAppName(cfg, raw); err != nil { + return err + } + if err := parseIconPath(cfg, raw); err != nil { + return err + } + return parseTimeoutMS(cfg, raw) +} + +func parseSoundEnabled(cfg *systemConfig, raw map[string]interface{}) error { + value, ok := raw["sound_enabled"] + if !ok { + return nil + } + enabled, ok := value.(bool) + if !ok { + return fmt.Errorf("sound_enabled must be a boolean") + } + cfg.SoundEnabled = enabled + return nil +} + +func parseSoundFile(cfg *systemConfig, raw map[string]interface{}) error { + value, ok := raw["sound_file"] + if !ok { + return nil + } + text, ok := value.(string) + if !ok { + return fmt.Errorf("sound_file must be a string") + } + cfg.SoundFile = strings.TrimSpace(text) + return nil +} + +func parseAppName(cfg *systemConfig, raw map[string]interface{}) error { + value, ok := raw["app_name"] + if !ok { + return nil + } + text, ok := value.(string) + if !ok { + return fmt.Errorf("app_name must be a string") + } + if trimmed := strings.TrimSpace(text); trimmed != "" { + cfg.AppName = trimmed + } + return nil +} + +func parseIconPath(cfg *systemConfig, raw map[string]interface{}) error { + value, ok := raw["icon_path"] + if !ok { + return nil + } + text, ok := value.(string) + if !ok { + return fmt.Errorf("icon_path must be a string") + } + cfg.IconPath = strings.TrimSpace(text) + return nil +} + +func parseTimeoutMS(cfg *systemConfig, raw map[string]interface{}) error { + value, ok := raw["timeout_ms"] + if !ok { + return nil + } + switch v := value.(type) { + case float64: + cfg.TimeoutMS = int(v) + case int: + cfg.TimeoutMS = v + case int64: + cfg.TimeoutMS = int(v) + case string: + if strings.TrimSpace(v) != "" { + parsed, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return fmt.Errorf("timeout_ms must be a number") + } + cfg.TimeoutMS = parsed + } + default: + return fmt.Errorf("timeout_ms must be a number") + } + return nil +} + func (p *SystemProvider) sendNotification(ctx context.Context, cfg systemConfig, title, body string) error { switch runtime.GOOS { - case "darwin": + case osDarwin: return runCommand(ctx, "osascript", "-e", buildAppleScript(title, body)) - case "linux": + case osLinux: if isWSL() { return p.sendWindowsNotification(ctx, cfg, title, body) } return sendLinuxNotification(ctx, cfg, title, body) - case "windows": + case osWindows: return p.sendWindowsNotification(ctx, cfg, title, body) default: return fmt.Errorf("system notifications not supported on %s", runtime.GOOS) @@ -204,13 +260,13 @@ func sendLinuxNotification(ctx context.Context, cfg systemConfig, title, body st func (p *SystemProvider) playSound(ctx context.Context, cfg systemConfig) error { switch runtime.GOOS { - case "darwin": + case osDarwin: soundPath := cfg.SoundFile if soundPath == "" { soundPath = "/System/Library/Sounds/Glass.aiff" } return runCommand(ctx, "afplay", soundPath) - case "linux": + case osLinux: if isWSL() { return p.playWindowsSound(ctx, cfg) } @@ -223,7 +279,7 @@ func (p *SystemProvider) playSound(ctx context.Context, cfg systemConfig) error } } return runCommand(ctx, "sh", "-c", "printf '\\a'") - case "windows": + case osWindows: return p.playWindowsSound(ctx, cfg) default: return nil @@ -269,7 +325,7 @@ func runCommand(ctx context.Context, name string, args ...string) error { } func isWSL() bool { - if runtime.GOOS != "linux" { + if runtime.GOOS != osLinux { return false } if os.Getenv("WSL_DISTRO_NAME") != "" || os.Getenv("WSL_INTEROP") != "" { diff --git a/apps/backend/internal/notifications/service/service.go b/apps/backend/internal/notifications/service/service.go index 5a9e3f2708..99369253c7 100644 --- a/apps/backend/internal/notifications/service/service.go +++ b/apps/backend/internal/notifications/service/service.go @@ -267,29 +267,35 @@ func (s *Service) ensureDefaultProviders(ctx context.Context, userID string) err } } if !hasSystem { - adapter := s.providers[models.ProviderTypeSystem] - if adapter != nil && adapter.Available() { - provider := &models.Provider{ - ID: uuid.New().String(), - UserID: userID, - Name: "System Notifications", - Type: models.ProviderTypeSystem, - Config: map[string]interface{}{ - "sound_enabled": false, - }, - Enabled: true, - } - if err := s.repo.CreateProvider(ctx, provider); err != nil { - return err - } - if err := s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{EventTaskSessionWaitingForInput}); err != nil { - return err - } + if err := s.ensureSystemProvider(ctx, userID); err != nil { + return err } } return nil } +// ensureSystemProvider creates the system notification provider if the adapter is available. +func (s *Service) ensureSystemProvider(ctx context.Context, userID string) error { + adapter := s.providers[models.ProviderTypeSystem] + if adapter == nil || !adapter.Available() { + return nil + } + provider := &models.Provider{ + ID: uuid.New().String(), + UserID: userID, + Name: "System Notifications", + Type: models.ProviderTypeSystem, + Config: map[string]interface{}{ + "sound_enabled": false, + }, + Enabled: true, + } + if err := s.repo.CreateProvider(ctx, provider); err != nil { + return err + } + return s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{EventTaskSessionWaitingForInput}) +} + func (s *Service) validateProvider(providerType models.ProviderType, config map[string]interface{}) error { adapter := s.providers[providerType] if adapter == nil { diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index a11b18680c..7f4d60d3be 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -3,20 +3,18 @@ package orchestrator import ( "context" - "fmt" - "strings" - "time" "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/internal/events" - "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" - wfmodels "github.com/kandev/kandev/internal/workflow/models" - v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Agent event type string constants. +const ( + agentEventComplete = "complete" + agentEventError = "error" ) // toolKindToMessageType maps the normalized tool kind to a frontend message type. @@ -86,1807 +84,3 @@ func (s *Service) storeResumeToken(ctx context.Context, taskID, sessionID, acpSe zap.String("session_id", sessionID), zap.String("resume_token", acpSessionID)) } - -// handleAgentRunning handles agent running events (user sent input in passthrough mode) -// This is called when the user sends input to the agent, indicating a new turn started. -func (s *Service) handleAgentRunning(ctx context.Context, data watcher.AgentEventData) { - if data.SessionID == "" { - s.logger.Warn("missing session_id for agent running event", - zap.String("task_id", data.TaskID)) - return - } - - // Process on_turn_start workflow events (step transitions). - // For ACP sessions this happens in the message handler before PromptTask; - // for passthrough it happens here when the PTY detects user input. - s.processOnTurnStart(ctx, data.TaskID, data.SessionID) - - // Move session to running and task to in progress - s.setSessionRunning(ctx, data.TaskID, data.SessionID) -} - -// publishQueueStatusEvent publishes a queue status changed event for the given session -func (s *Service) publishQueueStatusEvent(ctx context.Context, sessionID string) { - if s.eventBus == nil { - return - } - - queueStatus := s.messageQueue.GetStatus(ctx, sessionID) - eventData := map[string]interface{}{ - "session_id": sessionID, - "is_queued": queueStatus.IsQueued, - "message": queueStatus.Message, - } - - s.logger.Debug("publishing queue status changed event", - zap.String("session_id", sessionID), - zap.Bool("is_queued", queueStatus.IsQueued)) - - _ = s.eventBus.Publish(ctx, events.MessageQueueStatusChanged, bus.NewEvent( - events.MessageQueueStatusChanged, - "orchestrator", - eventData, - )) -} - -// handleAgentReady handles agent ready events (turn complete in passthrough mode) -// This is called when the agent finishes processing and is waiting for input. -func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventData) { - if data.SessionID == "" { - s.logger.Warn("missing session_id for agent ready event", - zap.String("task_id", data.TaskID)) - return - } - - // Complete the current turn - s.completeTurnForSession(ctx, data.SessionID) - - // Check for workflow transition based on session's current step - // This handles the case where the agent finishes a step and should move to the next - transitioned := s.processOnTurnComplete(ctx, data.TaskID, data.SessionID) - - // If no workflow transition occurred, move session to waiting for input and task to review - if !transitioned { - s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID) - } - - // ALWAYS check for queued messages after agent becomes ready, regardless of workflow transition - queueStatus := s.messageQueue.GetStatus(ctx, data.SessionID) - s.logger.Info("checking for queued messages", - zap.String("session_id", data.SessionID), - zap.Bool("is_queued", queueStatus.IsQueued), - zap.Any("message", queueStatus.Message)) - - // Skip queued messages for passthrough sessions — PromptTask uses ACP which - // doesn't work for passthrough, and writing to PTY stdin is unreliable. - // Check before TakeQueued to avoid destructively removing the message. - if s.agentManager.IsPassthroughSession(ctx, data.SessionID) { - s.logger.Info("skipping queued message for passthrough session", - zap.String("session_id", data.SessionID)) - return - } - - queuedMsg, exists := s.messageQueue.TakeQueued(ctx, data.SessionID) - if !exists { - s.logger.Debug("no queued message to execute", - zap.String("session_id", data.SessionID)) - return - } - - // Skip if the queued message has empty content (might have been cleared accidentally) - if queuedMsg.Content == "" && len(queuedMsg.Attachments) == 0 { - s.logger.Warn("skipping empty queued message", - zap.String("session_id", data.SessionID), - zap.String("queue_id", queuedMsg.ID)) - - // Still publish status change to clear frontend state - s.publishQueueStatusEvent(ctx, data.SessionID) - return - } - - s.logger.Info("auto-executing queued message", - zap.String("session_id", data.SessionID), - zap.String("task_id", queuedMsg.TaskID), - zap.String("queue_id", queuedMsg.ID)) - - // Publish queue status changed event to notify frontend - s.publishQueueStatusEvent(ctx, data.SessionID) - - // Execute the queued message asynchronously - go func() { - promptCtx := context.Background() // Use a fresh context for async execution - - // Create user message for the queued message (so it appears in chat history) - if s.messageCreator != nil { - turnID := s.getActiveTurnID(queuedMsg.SessionID) - if turnID == "" { - // Start a new turn if needed - s.startTurnForSession(promptCtx, queuedMsg.SessionID) - turnID = s.getActiveTurnID(queuedMsg.SessionID) - } - - // Note: Attachments will be sent to the agent via PromptTask but not stored in the message - // This matches the behavior of direct prompts - meta := NewUserMessageMeta().WithPlanMode(queuedMsg.PlanMode) - err := s.messageCreator.CreateUserMessage(promptCtx, queuedMsg.TaskID, queuedMsg.Content, queuedMsg.SessionID, turnID, meta.ToMap()) - if err != nil { - s.logger.Error("failed to create user message for queued message", - zap.String("session_id", queuedMsg.SessionID), - zap.Error(err)) - // Continue anyway - the prompt should still be sent - } - } - - // Convert queue attachments to v1 attachments - attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments)) - for i, att := range queuedMsg.Attachments { - attachments[i] = v1.MessageAttachment{ - Type: att.Type, - Data: att.Data, - MimeType: att.MimeType, - } - } - - _, err := s.PromptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID, - queuedMsg.Content, queuedMsg.Model, queuedMsg.PlanMode, attachments) - if err != nil { - s.logger.Error("failed to execute queued message", - zap.String("session_id", data.SessionID), - zap.String("task_id", queuedMsg.TaskID), - zap.String("queue_id", queuedMsg.ID), - zap.Error(err)) - - // TODO: Implement dead letter queue for failed queued messages - // Currently, failed messages are lost. Consider: - // 1. Retry mechanism with exponential backoff - // 2. Persist failed messages to database for manual intervention - // 3. Notification to user about failed queue execution - s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)", - zap.String("session_id", data.SessionID), - zap.String("queue_id", queuedMsg.ID), - zap.String("content_preview", queuedMsg.Content[:min(50, len(queuedMsg.Content))])) - } - }() -} - -// handleAgentCompleted handles agent completion events -func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) { - s.logger.Info("handling agent completed", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), - zap.String("agent_execution_id", data.AgentExecutionID)) - - // Update scheduler and remove from queue - s.scheduler.HandleTaskCompleted(data.TaskID, true) - s.scheduler.RemoveTask(data.TaskID) - - // Check for workflow transition based on session's current step - transitioned := s.processOnTurnComplete(ctx, data.TaskID, data.SessionID) - - // If no workflow transition occurred, move task to REVIEW state for user review - if !transitioned { - if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil { - s.logger.Error("failed to update task state to REVIEW", - zap.String("task_id", data.TaskID), - zap.Error(err)) - } else { - s.logger.Info("task moved to REVIEW state after agent completion", - zap.String("task_id", data.TaskID)) - } - } -} - -// processOnTurnComplete processes the on_turn_complete events for the current step. -// Returns true if a transition occurred (step change happened). -func (s *Service) processOnTurnComplete(ctx context.Context, taskID, sessionID string) bool { - if sessionID == "" || s.workflowStepGetter == nil { - return false - } - - // Get the session to find its current workflow step - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - s.logger.Warn("failed to get session for workflow transition", - zap.String("session_id", sessionID), - zap.Error(err)) - return false - } - - if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { - s.logger.Debug("session has no workflow step, skipping transition", - zap.String("session_id", sessionID)) - return false - } - - workflowStepID := *session.WorkflowStepID - - // Get the current workflow step - currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) - if err != nil { - s.logger.Warn("failed to get workflow step for transition", - zap.String("workflow_step_id", workflowStepID), - zap.Error(err)) - return false - } - - // If no on_turn_complete actions, do nothing (manual step) - if len(currentStep.Events.OnTurnComplete) == 0 { - s.logger.Debug("step has no on_turn_complete actions, waiting for user", - zap.String("step_id", currentStep.ID), - zap.String("step_name", currentStep.Name)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return false - } - - // Process side-effect actions first, then find the first transition action - var transitionAction *wfmodels.OnTurnCompleteAction - for i := range currentStep.Events.OnTurnComplete { - action := ¤tStep.Events.OnTurnComplete[i] - switch action.Type { - case wfmodels.OnTurnCompleteDisablePlanMode: - // Side-effect: clear plan mode from session metadata - s.clearSessionPlanMode(ctx, sessionID) - case wfmodels.OnTurnCompleteMoveToNext, wfmodels.OnTurnCompleteMoveToPrevious, wfmodels.OnTurnCompleteMoveToStep: - // Skip transition actions that require approval (handled via manual approval flow) - if action.Config != nil { - if ra, ok := action.Config["requires_approval"].(bool); ok && ra { - continue - } - } - if transitionAction == nil { - transitionAction = action - } - } - } - - // If no transition action found, just apply side effects and wait - if transitionAction == nil { - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return false - } - - // Resolve the target step ID - var targetStepID string - switch transitionAction.Type { - case wfmodels.OnTurnCompleteMoveToNext: - nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) - if err != nil { - s.logger.Warn("failed to get next step by position", - zap.String("workflow_id", currentStep.WorkflowID), - zap.Int("current_position", currentStep.Position), - zap.Error(err)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return false - } - if nextStep == nil { - s.logger.Debug("no next step found (last step), staying", - zap.String("step_name", currentStep.Name)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return false - } - targetStepID = nextStep.ID - case wfmodels.OnTurnCompleteMoveToPrevious: - prevStep, err := s.workflowStepGetter.GetPreviousStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) - if err != nil { - s.logger.Warn("failed to get previous step by position", - zap.String("workflow_id", currentStep.WorkflowID), - zap.Int("current_position", currentStep.Position), - zap.Error(err)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return false - } - if prevStep == nil { - s.logger.Debug("no previous step found (first step), staying", - zap.String("step_name", currentStep.Name)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return false - } - targetStepID = prevStep.ID - case wfmodels.OnTurnCompleteMoveToStep: - if transitionAction.Config != nil { - if sid, ok := transitionAction.Config["step_id"].(string); ok { - targetStepID = sid - } - } - if targetStepID == "" { - s.logger.Warn("move_to_step action missing step_id config", - zap.String("step_id", currentStep.ID)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return false - } - } - - // Execute the step transition - s.executeStepTransition(ctx, taskID, sessionID, currentStep, targetStepID, true) - return true -} - -// processOnTurnStart processes the on_turn_start events for the current step. -// This is called when a user sends a message. Returns true if a transition occurred. -func (s *Service) processOnTurnStart(ctx context.Context, taskID, sessionID string) bool { - if sessionID == "" || s.workflowStepGetter == nil { - return false - } - - // Get the session to find its current workflow step - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - s.logger.Warn("failed to get session for on_turn_start", - zap.String("session_id", sessionID), - zap.Error(err)) - return false - } - - if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { - return false - } - - workflowStepID := *session.WorkflowStepID - - // Get the current workflow step - currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) - if err != nil { - s.logger.Warn("failed to get workflow step for on_turn_start", - zap.String("workflow_step_id", workflowStepID), - zap.Error(err)) - return false - } - - // If no on_turn_start actions, do nothing - if len(currentStep.Events.OnTurnStart) == 0 { - return false - } - - // Find the first transition action - var transitionAction *wfmodels.OnTurnStartAction - for i := range currentStep.Events.OnTurnStart { - action := ¤tStep.Events.OnTurnStart[i] - switch action.Type { - case wfmodels.OnTurnStartMoveToNext, wfmodels.OnTurnStartMoveToPrevious, wfmodels.OnTurnStartMoveToStep: - if transitionAction == nil { - transitionAction = action - } - } - } - - if transitionAction == nil { - return false - } - - // Resolve the target step ID - var targetStepID string - switch transitionAction.Type { - case wfmodels.OnTurnStartMoveToNext: - nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) - if err != nil || nextStep == nil { - return false - } - targetStepID = nextStep.ID - case wfmodels.OnTurnStartMoveToPrevious: - prevStep, err := s.workflowStepGetter.GetPreviousStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) - if err != nil || prevStep == nil { - return false - } - targetStepID = prevStep.ID - case wfmodels.OnTurnStartMoveToStep: - if transitionAction.Config != nil { - if sid, ok := transitionAction.Config["step_id"].(string); ok { - targetStepID = sid - } - } - if targetStepID == "" { - return false - } - } - - s.logger.Info("on_turn_start triggered step transition", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("from_step", currentStep.Name), - zap.String("action", string(transitionAction.Type))) - - // Execute the step transition WITHOUT triggering on_enter auto-start - // (user is about to send a message, the prompt will come from them) - s.executeStepTransition(ctx, taskID, sessionID, currentStep, targetStepID, false) - return true -} - -// ProcessOnTurnStart is the public API for triggering on_turn_start events. -// Called by message handlers before sending a prompt to the agent. -func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { - s.processOnTurnStart(ctx, taskID, sessionID) - return nil -} - -// executeStepTransition moves a task/session from one step to another. -// If triggerOnEnter is true, on_enter actions (like auto_start_agent) are processed. -// If false, only the step change is applied (used for on_turn_start where the user is about to send a message). -func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID string, fromStep *wfmodels.WorkflowStep, toStepID string, triggerOnEnter bool) { - // Process on_exit actions for the step we're leaving (before the step change) - s.processOnExit(ctx, taskID, sessionID, fromStep) - - // Get the target step - targetStep, err := s.workflowStepGetter.GetStep(ctx, toStepID) - if err != nil { - s.logger.Warn("failed to get target workflow step", - zap.String("target_step_id", toStepID), - zap.Error(err)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return - } - - // Get the task to update its workflow step - task, err := s.repo.GetTask(ctx, taskID) - if err != nil { - s.logger.Warn("failed to get task for workflow transition", - zap.String("task_id", taskID), - zap.Error(err)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return - } - - // Update the task's workflow step - task.WorkflowStepID = toStepID - task.UpdatedAt = time.Now().UTC() - if err := s.repo.UpdateTask(ctx, task); err != nil { - s.logger.Error("failed to move task to next workflow step", - zap.String("task_id", taskID), - zap.String("from_step", fromStep.Name), - zap.String("to_step", targetStep.Name), - zap.Error(err)) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - return - } - - // Publish task updated event - if s.eventBus != nil { - taskEventData := map[string]interface{}{ - "task_id": task.ID, - "workflow_id": task.WorkflowID, - "workflow_step_id": task.WorkflowStepID, - "title": task.Title, - "description": task.Description, - "state": string(task.State), - "priority": task.Priority, - "position": task.Position, - } - _ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent( - events.TaskUpdated, - "orchestrator", - taskEventData, - )) - } - - // Update session's workflow step - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, toStepID); err != nil { - s.logger.Warn("failed to update session workflow step", - zap.String("session_id", sessionID), - zap.String("step_id", toStepID), - zap.Error(err)) - } - - // Clear review status when transitioning - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { - s.logger.Warn("failed to clear session review status", - zap.String("session_id", sessionID), - zap.Error(err)) - } - - s.logger.Info("workflow transition completed", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("from_step", fromStep.Name), - zap.String("to_step", targetStep.Name), - zap.Bool("trigger_on_enter", triggerOnEnter)) - - // Process on_enter for the target step (skip if triggerOnEnter is false, - // e.g. on_turn_start transitions where the user is about to send a message) - if triggerOnEnter { - s.processOnEnter(ctx, taskID, sessionID, targetStep, task.Description) - } else { - s.setSessionWaitingForInput(ctx, taskID, sessionID) - } -} - -// processOnEnter processes the on_enter events for a step after transitioning to it. -func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep, taskDescription string) { - isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) - - // Check if this step enables plan mode - hasPlanMode := false - for _, action := range step.Events.OnEnter { - if action.Type == wfmodels.OnEnterEnablePlanMode { - hasPlanMode = true - break - } - } - - // Skip plan mode management for passthrough sessions — the CLI manages its own state. - // For ACP sessions, auto-disable plan mode when entering a step that doesn't explicitly enable it. - if !isPassthrough { - if !hasPlanMode { - s.clearSessionPlanMode(ctx, sessionID) - } - } - - if len(step.Events.OnEnter) == 0 { - s.setSessionWaitingForInput(ctx, taskID, sessionID) - - // Publish session state change - if s.eventBus != nil { - eventData := map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "workflow_step_id": step.ID, - "new_state": string(models.TaskSessionStateWaitingForInput), - } - _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( - events.TaskSessionStateChanged, - "orchestrator", - eventData, - )) - } - return - } - - hasAutoStart := false - for _, action := range step.Events.OnEnter { - switch action.Type { - case wfmodels.OnEnterEnablePlanMode: - // Skip plan mode for passthrough — CLI manages its own state - if !isPassthrough { - s.setSessionPlanMode(ctx, sessionID, true) - } - case wfmodels.OnEnterAutoStartAgent: - hasAutoStart = true - } - } - - // Skip auto-start for passthrough sessions — stdin is unreliable and the - // process may not be expecting input. Let the user interact directly. - if hasAutoStart && !isPassthrough { - // Build prompt from step configuration - effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID) - planMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) - - // Auto-start the agent via ACP prompt - go func() { - bgCtx := context.Background() - _, err := s.PromptTask(bgCtx, taskID, sessionID, effectivePrompt, "", planMode, nil) - if err != nil { - s.logger.Error("failed to auto-start agent for step", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("step_name", step.Name), - zap.Error(err)) - s.setSessionWaitingForInput(bgCtx, taskID, sessionID) - } - }() - } else { - s.setSessionWaitingForInput(ctx, taskID, sessionID) - - // Publish session state change - if s.eventBus != nil { - eventData := map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "workflow_step_id": step.ID, - "new_state": string(models.TaskSessionStateWaitingForInput), - } - _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( - events.TaskSessionStateChanged, - "orchestrator", - eventData, - )) - } - } -} - -// processOnExit processes the on_exit events for a step when leaving it. -// This is called before transitioning to the next step. Only side-effect actions -// are supported (no transitions — those are decided by on_turn_complete). -func (s *Service) processOnExit(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep) { - if len(step.Events.OnExit) == 0 { - return - } - - // Skip plan mode management for passthrough sessions — the CLI manages its own state. - isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) - - for _, action := range step.Events.OnExit { - if action.Type == wfmodels.OnExitDisablePlanMode && !isPassthrough { - s.clearSessionPlanMode(ctx, sessionID) - s.logger.Debug("on_exit: disabled plan mode", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("step_name", step.Name)) - } - } -} - -// clearSessionPlanMode clears plan mode from session metadata. -func (s *Service) clearSessionPlanMode(ctx context.Context, sessionID string) { - s.setSessionPlanMode(ctx, sessionID, false) -} - -// setSessionPlanMode sets or clears plan mode in session metadata. -func (s *Service) setSessionPlanMode(ctx context.Context, sessionID string, enabled bool) { - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - s.logger.Warn("failed to get session for plan mode update", - zap.String("session_id", sessionID), - zap.Error(err)) - return - } - - if session.Metadata == nil { - session.Metadata = make(map[string]interface{}) - } - - if enabled { - session.Metadata["plan_mode"] = true - } else { - delete(session.Metadata, "plan_mode") - } - - if err := s.repo.UpdateTaskSession(ctx, session); err != nil { - s.logger.Warn("failed to update session plan mode", - zap.String("session_id", sessionID), - zap.Bool("enabled", enabled), - zap.Error(err)) - } -} - -// handleAgentFailed handles agent failure events -func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) { - s.logger.Warn("handling agent failed", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), - zap.String("agent_execution_id", data.AgentExecutionID), - zap.String("error_message", data.ErrorMessage)) - - // Check if this was a resume failure (agent session no longer exists on the CLI side). - // If so, clear the resume token and re-launch a fresh session instead of failing. - // We do this BEFORE setting the session state to FAILED so the re-launch can proceed cleanly. - if data.SessionID != "" && isResumeFailure(data.ErrorMessage) { - if s.handleResumeFailure(ctx, data) { - return // Successfully re-launched without resume - } - // Fall through to normal failure handling if re-launch failed - } - - // Update session state to FAILED - if data.SessionID != "" { - s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateFailed, data.ErrorMessage, false) - } - - // Trigger retry logic - s.scheduler.HandleTaskCompleted(data.TaskID, false) - if !s.scheduler.RetryTask(data.TaskID) { - s.logger.Error("task failed and retry limit exceeded", - zap.String("task_id", data.TaskID)) - - // Move task to REVIEW state even on failure - user can decide to retry or close - // This maintains the review cycle: user reviews the failure and decides next steps - if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil { - s.logger.Error("failed to update task state to REVIEW after failure", - zap.String("task_id", data.TaskID), - zap.Error(err)) - } else { - s.logger.Info("task moved to REVIEW state after failure (for user review)", - zap.String("task_id", data.TaskID)) - } - } else { - // If retry is triggered, also move task to REVIEW state - // The retry will start a new agent when the task is re-launched - if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil { - s.logger.Error("failed to update task state to REVIEW after failure (with retry pending)", - zap.String("task_id", data.TaskID), - zap.Error(err)) - } - } -} - -// isResumeFailure checks if the error message indicates a failed --resume attempt -// (e.g., the Claude Code conversation was deleted or expired). -func isResumeFailure(errorMsg string) bool { - lower := strings.ToLower(errorMsg) - return strings.Contains(lower, "no conversation found") -} - -// handleResumeFailure handles the case where an agent failed because --resume pointed -// to a non-existent conversation. It clears the resume token, sends a status message, -// and schedules an asynchronous re-launch of the session without resume. -// -// The re-launch must be async because this handler is called synchronously from -// MarkCompleted → PublishAgentEvent, BEFORE RemoveExecution cleans up the failed -// execution from the manager's store. Calling ResumeSession synchronously would -// fail with ErrExecutionAlreadyRunning. -// -// Returns true to signal that the caller should skip normal failure handling -// (scheduler retry, FAILED state) since we're handling the retry ourselves. -func (s *Service) handleResumeFailure(ctx context.Context, data watcher.AgentEventData) bool { - s.logger.Warn("detected resume failure, retrying without resume", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), - zap.String("error", data.ErrorMessage)) - - // 1. Clear the resume token so the next attempt won't use --resume. - // Note: executor.ResumeSession already upserts a new ExecutorRunning without ResumeToken, - // but clear it explicitly to be safe against race conditions. - running, err := s.repo.GetExecutorRunningBySessionID(ctx, data.SessionID) - if err == nil && running != nil && running.ResumeToken != "" { - running.ResumeToken = "" - if upsertErr := s.repo.UpsertExecutorRunning(ctx, running); upsertErr != nil { - s.logger.Error("failed to clear resume token", - zap.String("session_id", data.SessionID), - zap.Error(upsertErr)) - } - } - - // 2. Send a status message about the failed resume - if s.messageCreator != nil { - statusMsg := fmt.Sprintf("Session resume failed: %s. Starting a fresh session...", data.ErrorMessage) - if err := s.messageCreator.CreateSessionMessage( - ctx, - data.TaskID, - statusMsg, - data.SessionID, - string(v1.MessageTypeStatus), - s.getActiveTurnID(data.SessionID), - map[string]interface{}{ - "variant": "warning", - "resume_failed": true, - }, - false, - ); err != nil { - s.logger.Warn("failed to create resume failure status message", - zap.String("task_id", data.TaskID), - zap.Error(err)) - } - } - - // 3. Schedule async re-launch. We use a goroutine because this handler runs - // synchronously inside the MarkCompleted → PublishAgentEvent call chain, - // before RemoveExecution clears the old execution from the manager's store. - // A synchronous ResumeSession call would fail with ErrExecutionAlreadyRunning. - taskID := data.TaskID - sessionID := data.SessionID - go func() { - // Brief delay to let RemoveExecution complete after event handler returns - time.Sleep(500 * time.Millisecond) - bgCtx := context.Background() - - session, err := s.repo.GetTaskSession(bgCtx, sessionID) - if err != nil { - s.logger.Error("failed to get session for resume retry", - zap.String("session_id", sessionID), - zap.Error(err)) - _ = s.repo.UpdateTaskSessionState(bgCtx, sessionID, models.TaskSessionStateFailed, "re-launch failed: "+err.Error()) - return - } - - _, err = s.executor.ResumeSession(bgCtx, session, true) - if err != nil { - s.logger.Error("failed to re-launch session after resume failure", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(err)) - _ = s.repo.UpdateTaskSessionState(bgCtx, sessionID, models.TaskSessionStateFailed, "re-launch failed: "+err.Error()) - return - } - - s.logger.Info("session re-launched after resume failure (fresh session without --resume)", - zap.String("task_id", taskID), - zap.String("session_id", sessionID)) - }() - - return true -} - -// handleAgentStopped handles agent stopped events (manual stop or cancellation) -func (s *Service) handleAgentStopped(ctx context.Context, data watcher.AgentEventData) { - s.logger.Info("handling agent stopped", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), - zap.String("agent_execution_id", data.AgentExecutionID)) - - // Complete the current turn if there is one - s.completeTurnForSession(ctx, data.SessionID) - - // Update session state to cancelled (already done by executor, but ensure consistency) - s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateCancelled, "", false) - - // NOTE: We do NOT update task state here because: - // 1. If this is from CompleteTask(), the task state will be set to COMPLETED by the caller - // 2. If this is from StopTask(), the task state should be set to REVIEW by the caller - // 3. Updating here would create a race condition with the caller's state update - // - // The task state management is the responsibility of the operation that triggered the stop, - // not the event handler. This handler only manages session-level cleanup. -} - -// handleAgentStreamEvent handles agent stream events (tool calls, message chunks, etc.) -func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - if payload == nil || payload.Data == nil { - return - } - - taskID := payload.TaskID - sessionID := payload.SessionID - eventType := payload.Data.Type - - s.logger.Debug("handling agent stream event", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("event_type", eventType)) - - // Handle different event types - switch eventType { - case "message_streaming": - s.handleMessageStreamingEvent(ctx, payload) - - case "thinking_streaming": - s.handleThinkingStreamingEvent(ctx, payload) - - case "tool_call": - s.saveAgentTextIfPresent(ctx, payload) - s.handleToolCallEvent(ctx, payload) - - case "tool_update": - s.handleToolUpdateEvent(ctx, payload) - - case "complete": - s.logger.Debug("orchestrator received complete event", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Int("text_length", len(payload.Data.Text)), - zap.Bool("has_text", payload.Data.Text != "")) - s.saveAgentTextIfPresent(ctx, payload) - s.completeTurnForSession(ctx, sessionID) - s.setSessionWaitingForInput(ctx, taskID, sessionID) - - case "error": - // Handle error events - // Note: Agent-specific error parsing is done in the adapter layer. - // The adapter provides a user-friendly message in the Text field, - // with raw details in the Data field for debugging. - if sessionID != "" && s.messageCreator != nil { - // Build error message - adapters should provide parsed message in Text field - errorMsg := payload.Data.Error - if errorMsg == "" { - errorMsg = payload.Data.Text - } - if errorMsg == "" { - errorMsg = "An error occurred while processing your request" - } - - // Build metadata with all available error context - metadata := map[string]interface{}{ - "provider": "agent", - "provider_agent": payload.AgentID, - } - if payload.Data.Data != nil { - metadata["error_data"] = payload.Data.Data - } - - if err := s.messageCreator.CreateSessionMessage( - ctx, - taskID, - errorMsg, - sessionID, - string(v1.MessageTypeError), - s.getActiveTurnID(sessionID), - metadata, - false, - ); err != nil { - s.logger.Error("failed to create error message", - zap.String("task_id", taskID), - zap.Error(err)) - } - } - // Complete the turn since the agent errored - s.completeTurnForSession(ctx, sessionID) - - case "session_status": - // Handle session status events (resumed vs new session) - // Also store the agent's session ID as resume token for session recovery - if sessionID != "" && payload.Data.ACPSessionID != "" { - s.storeResumeToken(ctx, taskID, sessionID, payload.Data.ACPSessionID) - } - - if sessionID != "" && s.messageCreator != nil { - var statusMsg string - if payload.Data.SessionStatus == "resumed" { - statusMsg = "Session resumed" - } else { - statusMsg = "New session started" - } - if err := s.messageCreator.CreateSessionMessage( - ctx, - taskID, - statusMsg, - sessionID, - string(v1.MessageTypeStatus), - s.getActiveTurnID(sessionID), - nil, - false, - ); err != nil { - s.logger.Error("failed to create session status message", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(err)) - } - } - - case "available_commands": - // Handle available commands events - broadcast to WebSocket for frontend - if sessionID != "" && s.eventBus != nil && len(payload.Data.AvailableCommands) > 0 { - eventPayload := lifecycle.AvailableCommandsEventPayload{ - TaskID: taskID, - SessionID: sessionID, - AgentID: payload.AgentID, - AvailableCommands: payload.Data.AvailableCommands, - } - subject := events.BuildAvailableCommandsSubject(sessionID) - _ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.AvailableCommandsUpdated, "orchestrator", eventPayload)) - } - - case "permission_cancelled": - // Handle permission cancellation — mark the permission message as expired - if sessionID != "" && payload.Data.PendingID != "" && s.messageCreator != nil { - if err := s.messageCreator.UpdatePermissionMessage(ctx, sessionID, payload.Data.PendingID, "expired"); err != nil { - s.logger.Warn("failed to mark permission as expired", - zap.String("session_id", sessionID), - zap.String("pending_id", payload.Data.PendingID), - zap.Error(err)) - } - } - - case "log": - // Handle log events - store agent log messages to the database - if sessionID != "" && s.messageCreator != nil { - // Try to extract data as map - dataMap, _ := payload.Data.Data.(map[string]interface{}) - - // Extract log message content - logMsg := payload.Data.Text - if logMsg == "" && dataMap != nil { - if msg, ok := dataMap["message"].(string); ok { - logMsg = msg - } - } - if logMsg == "" { - return // No message content to store - } - - // Build metadata with log level and other context - metadata := map[string]interface{}{ - "provider": "agent", - "provider_agent": payload.AgentID, - } - if dataMap != nil { - if level, ok := dataMap["level"].(string); ok { - metadata["level"] = level - } - // Include any additional data from the log event - for k, v := range dataMap { - if k != "message" && k != "level" { - metadata[k] = v - } - } - } - - if err := s.messageCreator.CreateSessionMessage( - ctx, - taskID, - logMsg, - sessionID, - string(v1.MessageTypeLog), - s.getActiveTurnID(sessionID), - metadata, - false, - ); err != nil { - s.logger.Error("failed to create log message", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(err)) - } else { - level := "unknown" - if l, ok := metadata["level"].(string); ok { - level = l - } - s.logger.Debug("created log message", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("level", level)) - } - } - } -} - -// handleToolCallEvent handles tool_call events and creates messages -func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - if payload.SessionID == "" { - s.logger.Warn("missing session_id for tool_call", - zap.String("task_id", payload.TaskID), - zap.String("tool_call_id", payload.Data.ToolCallID)) - return - } - - if s.messageCreator != nil { - if err := s.messageCreator.CreateToolCallMessage( - ctx, - payload.TaskID, - payload.Data.ToolCallID, - payload.Data.ParentToolCallID, // Pass parent for subagent nesting - payload.Data.ToolTitle, - payload.Data.ToolStatus, - payload.SessionID, - s.getActiveTurnID(payload.SessionID), - payload.Data.Normalized, // Pass normalized tool data for message metadata - ); err != nil { - s.logger.Error("failed to create tool call message", - zap.String("task_id", payload.TaskID), - zap.String("tool_call_id", payload.Data.ToolCallID), - zap.Error(err)) - } else { - s.logger.Debug("created tool call message", - zap.String("task_id", payload.TaskID), - zap.String("tool_call_id", payload.Data.ToolCallID)) - } - - // Allow tool calls to wake session from WAITING_FOR_INPUT - // This ensures that when user responds to a clarification and the agent continues, - // the session properly transitions to RUNNING - s.updateTaskSessionState(ctx, payload.TaskID, payload.SessionID, models.TaskSessionStateRunning, "", true) - } -} - -// saveAgentTextIfPresent saves any accumulated agent text as an agent message -func (s *Service) saveAgentTextIfPresent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - if payload.Data.Text == "" || payload.SessionID == "" { - return - } - - if s.messageCreator != nil { - if err := s.messageCreator.CreateAgentMessage(ctx, payload.TaskID, payload.Data.Text, payload.SessionID, s.getActiveTurnID(payload.SessionID)); err != nil { - s.logger.Error("failed to create agent message", - zap.String("task_id", payload.TaskID), - zap.Error(err)) - } else { - s.logger.Debug("created agent message", - zap.String("task_id", payload.TaskID), - zap.Int("message_length", len(payload.Data.Text))) - } - } -} - -// handleMessageStreamingEvent handles streaming message events for real-time text updates. -// It creates a new message on first chunk (IsAppend=false) or appends to existing (IsAppend=true). -func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - if payload.Data.Text == "" || payload.SessionID == "" { - return - } - - if s.messageCreator == nil { - return - } - - // MessageID is pre-generated by the manager to avoid race conditions - messageID := payload.Data.MessageID - if messageID == "" { - s.logger.Warn("streaming message event missing message ID", - zap.String("task_id", payload.TaskID), - zap.String("session_id", payload.SessionID)) - return - } - - if payload.Data.IsAppend { - // Append to existing message - if err := s.messageCreator.AppendAgentMessage(ctx, messageID, payload.Data.Text); err != nil { - s.logger.Error("failed to append to streaming message", - zap.String("task_id", payload.TaskID), - zap.String("message_id", messageID), - zap.Error(err)) - } else { - s.logger.Debug("appended to streaming message", - zap.String("task_id", payload.TaskID), - zap.String("message_id", messageID), - zap.Int("content_length", len(payload.Data.Text))) - } - } else { - // Create new streaming message with the pre-generated ID - if err := s.messageCreator.CreateAgentMessageStreaming(ctx, messageID, payload.TaskID, payload.Data.Text, payload.SessionID, s.getActiveTurnID(payload.SessionID)); err != nil { - s.logger.Error("failed to create streaming message", - zap.String("task_id", payload.TaskID), - zap.String("message_id", messageID), - zap.Error(err)) - } else { - s.logger.Debug("created streaming message", - zap.String("task_id", payload.TaskID), - zap.String("session_id", payload.SessionID), - zap.String("message_id", messageID), - zap.Int("content_length", len(payload.Data.Text))) - } - } -} - -// handleThinkingStreamingEvent handles streaming thinking events for real-time reasoning updates. -// It creates a new thinking message on first chunk (IsAppend=false) or appends to existing (IsAppend=true). -func (s *Service) handleThinkingStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - if payload.Data.Text == "" || payload.SessionID == "" { - return - } - - if s.messageCreator == nil { - return - } - - // MessageID is pre-generated by the manager to avoid race conditions - messageID := payload.Data.MessageID - if messageID == "" { - s.logger.Warn("streaming thinking event missing message ID", - zap.String("task_id", payload.TaskID), - zap.String("session_id", payload.SessionID)) - return - } - - if payload.Data.IsAppend { - // Append to existing thinking message - if err := s.messageCreator.AppendThinkingMessage(ctx, messageID, payload.Data.Text); err != nil { - s.logger.Error("failed to append to streaming thinking message", - zap.String("task_id", payload.TaskID), - zap.String("message_id", messageID), - zap.Error(err)) - } else { - s.logger.Debug("appended to streaming thinking message", - zap.String("task_id", payload.TaskID), - zap.String("message_id", messageID), - zap.Int("content_length", len(payload.Data.Text))) - } - } else { - // Create new streaming thinking message with the pre-generated ID - if err := s.messageCreator.CreateThinkingMessageStreaming(ctx, messageID, payload.TaskID, payload.Data.Text, payload.SessionID, s.getActiveTurnID(payload.SessionID)); err != nil { - s.logger.Error("failed to create streaming thinking message", - zap.String("task_id", payload.TaskID), - zap.String("message_id", messageID), - zap.Error(err)) - } else { - s.logger.Debug("created streaming thinking message", - zap.String("task_id", payload.TaskID), - zap.String("session_id", payload.SessionID), - zap.String("message_id", messageID), - zap.Int("content_length", len(payload.Data.Text))) - } - } -} - -// handleToolUpdateEvent handles tool_update events and updates messages -func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - if payload.SessionID == "" { - s.logger.Warn("missing session_id for tool_update", - zap.String("task_id", payload.TaskID), - zap.String("tool_call_id", payload.Data.ToolCallID)) - return - } - - if s.messageCreator == nil { - return - } - - // Determine message type from normalized payload for fallback creation - msgType := toolKindToMessageType(payload.Data.Normalized) - - // Handle all status updates (running, complete, error) - switch payload.Data.ToolStatus { - case "running", "complete", "completed", "success", "error", "failed": - if err := s.messageCreator.UpdateToolCallMessage( - ctx, - payload.TaskID, - payload.Data.ToolCallID, - payload.Data.ParentToolCallID, // Pass parent for subagent nesting - payload.Data.ToolStatus, - "", // result - no longer used, tool results in NormalizedPayload - payload.SessionID, - payload.Data.ToolTitle, // Include title from update event - s.getActiveTurnID(payload.SessionID), // Turn ID for fallback creation - msgType, // Message type for fallback creation - payload.Data.Normalized, // Pass normalized tool data for message metadata - ); err != nil { - s.logger.Warn("failed to update tool call message", - zap.String("task_id", payload.TaskID), - zap.String("tool_call_id", payload.Data.ToolCallID), - zap.Error(err)) - } - - // Update session state for completion events - // Allow tool completions to wake session from WAITING_FOR_INPUT - if payload.Data.ToolStatus == "complete" || payload.Data.ToolStatus == "completed" || - payload.Data.ToolStatus == "success" || payload.Data.ToolStatus == "error" || payload.Data.ToolStatus == "failed" { - s.updateTaskSessionState(ctx, payload.TaskID, payload.SessionID, models.TaskSessionStateRunning, "", true) - } - } -} - -func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID string, nextState models.TaskSessionState, errorMessage string, allowWakeFromWaiting bool) { - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - return - } - if session.State == models.TaskSessionStateWaitingForInput && nextState == models.TaskSessionStateRunning && !allowWakeFromWaiting { - return - } - oldState := session.State - switch session.State { - case models.TaskSessionStateCompleted, models.TaskSessionStateFailed, models.TaskSessionStateCancelled: - return - } - if session.State == nextState { - return - } - if err := s.repo.UpdateTaskSessionState(ctx, sessionID, nextState, errorMessage); err != nil { - s.logger.Error("failed to update task session state", - zap.String("session_id", sessionID), - zap.String("state", string(nextState)), - zap.Error(err)) - } - s.logger.Debug("task session state updated", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("old_state", string(oldState)), - zap.String("new_state", string(nextState))) - if s.eventBus != nil { - eventData := map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "old_state": string(oldState), - "new_state": string(nextState), - "agent_profile_id": session.AgentProfileID, - "agent_profile_snapshot": session.AgentProfileSnapshot, - "is_passthrough": session.IsPassthrough, - } - // Include review_status and workflow_step_id if present to ensure frontend state consistency - if session.ReviewStatus != nil { - eventData["review_status"] = *session.ReviewStatus - } - if session.WorkflowStepID != nil { - eventData["workflow_step_id"] = *session.WorkflowStepID - } - _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(events.TaskSessionStateChanged, "task-session", eventData)) - } -} - -func (s *Service) setSessionWaitingForInput(ctx context.Context, taskID, sessionID string) { - s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateWaitingForInput, "", false) - - if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil { - s.logger.Error("failed to update task state to REVIEW", - zap.String("task_id", taskID), - zap.Error(err)) - } else { - s.logger.Info("task moved to REVIEW state", - zap.String("task_id", taskID)) - } -} - -func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID string) { - s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateRunning, "", true) - - if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil { - s.logger.Error("failed to update task state to IN_PROGRESS", - zap.String("task_id", taskID), - zap.Error(err)) - } else { - s.logger.Info("task moved to IN_PROGRESS state", - zap.String("task_id", taskID)) - } -} - -// handleGitEvent handles unified git events and dispatches to appropriate handler -func (s *Service) handleGitEvent(ctx context.Context, data watcher.GitEventData) { - s.logger.Debug("handling git event", - zap.String("type", string(data.Type)), - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID)) - - if data.SessionID == "" { - s.logger.Debug("missing session_id for git event", - zap.String("task_id", data.TaskID), - zap.String("type", string(data.Type))) - return - } - - switch data.Type { - case lifecycle.GitEventTypeStatusUpdate: - s.handleGitStatusUpdate(ctx, data) - case lifecycle.GitEventTypeCommitCreated: - s.handleGitCommitCreated(ctx, data) - case lifecycle.GitEventTypeCommitsReset: - s.handleGitCommitsReset(ctx, data) - case lifecycle.GitEventTypeSnapshotCreated: - // Snapshot events are published from orchestrator, no need to handle here - s.logger.Debug("received snapshot_created event, no action needed", - zap.String("session_id", data.SessionID)) - default: - s.logger.Warn("unknown git event type", - zap.String("type", string(data.Type)), - zap.String("session_id", data.SessionID)) - } -} - -// handleGitStatusUpdate handles git status updates by creating git snapshots -func (s *Service) handleGitStatusUpdate(ctx context.Context, data watcher.GitEventData) { - if data.Status == nil { - s.logger.Debug("missing status data for git status update", - zap.String("task_id", data.TaskID)) - return - } - - // Forward status_update event to WebSocket subject for frontend - // Since data is already lifecycle.GitEventPayload, we can forward it directly - if s.eventBus != nil { - event := bus.NewEvent(events.GitWSEvent, "orchestrator", &data) - _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) - } - - // Convert Files from interface{} to map[string]interface{} - var files map[string]interface{} - if data.Status.Files != nil { - if f, ok := data.Status.Files.(map[string]interface{}); ok { - files = f - } - } - - // Create git snapshot instead of storing in session metadata - snapshot := &models.GitSnapshot{ - SessionID: data.SessionID, - SnapshotType: models.SnapshotTypeStatusUpdate, - Branch: data.Status.Branch, - RemoteBranch: data.Status.RemoteBranch, - HeadCommit: data.Status.HeadCommit, - BaseCommit: data.Status.BaseCommit, - Ahead: data.Status.Ahead, - Behind: data.Status.Behind, - Files: files, - TriggeredBy: "git_status_event", - Metadata: map[string]interface{}{ - "modified": data.Status.Modified, - "added": data.Status.Added, - "deleted": data.Status.Deleted, - "untracked": data.Status.Untracked, - "renamed": data.Status.Renamed, - "timestamp": data.Timestamp, - }, - } - - sessionID := data.SessionID - taskID := data.TaskID - - // Persist snapshot to database asynchronously, but skip if duplicate of latest - go func() { - bgCtx := context.Background() - - // Check if this is a duplicate of the latest snapshot - latest, err := s.repo.GetLatestGitSnapshot(bgCtx, sessionID) - if err == nil && latest != nil { - // Compare key fields to detect duplicates - if s.isSnapshotDuplicate(latest, snapshot) { - s.logger.Debug("skipping duplicate git snapshot", - zap.String("task_id", taskID), - zap.String("session_id", sessionID)) - return - } - } - - if err := s.repo.CreateGitSnapshot(bgCtx, snapshot); err != nil { - s.logger.Error("failed to create git snapshot", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(err)) - } else { - s.logger.Debug("created git snapshot", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("snapshot_id", snapshot.ID)) - - // Publish event to notify frontend - if s.eventBus != nil { - event := bus.NewEvent(events.GitEvent, "orchestrator", &lifecycle.GitEventPayload{ - Type: lifecycle.GitEventTypeSnapshotCreated, - SessionID: sessionID, - TaskID: taskID, - Timestamp: snapshot.CreatedAt.Format("2006-01-02T15:04:05.000000000Z07:00"), - Snapshot: &lifecycle.GitSnapshotData{ - ID: snapshot.ID, - SessionID: snapshot.SessionID, - SnapshotType: string(snapshot.SnapshotType), - Branch: snapshot.Branch, - RemoteBranch: snapshot.RemoteBranch, - HeadCommit: snapshot.HeadCommit, - BaseCommit: snapshot.BaseCommit, - Ahead: snapshot.Ahead, - Behind: snapshot.Behind, - Files: snapshot.Files, - TriggeredBy: snapshot.TriggeredBy, - CreatedAt: snapshot.CreatedAt.Format("2006-01-02T15:04:05.000000000Z07:00"), - }, - }) - _ = s.eventBus.Publish(bgCtx, events.BuildGitWSEventSubject(sessionID), event) - } - } - }() -} - -// isSnapshotDuplicate checks if two snapshots have the same content -func (s *Service) isSnapshotDuplicate(existing, new *models.GitSnapshot) bool { - // Different snapshot types are never duplicates - if existing.SnapshotType != new.SnapshotType { - return false - } - - // Compare branch and commit info - if existing.Branch != new.Branch || - existing.HeadCommit != new.HeadCommit || - existing.Ahead != new.Ahead || - existing.Behind != new.Behind { - return false - } - - // Compare file counts first (quick check) - existingFileCount := len(existing.Files) - newFileCount := len(new.Files) - if existingFileCount != newFileCount { - return false - } - - // Compare file paths, staged status, line counts, and diff content - for path, newFileData := range new.Files { - existingFileData, exists := existing.Files[path] - if !exists { - return false - } - - // Compare file details - extract from interface{} - newInfo := extractFileInfo(newFileData) - existingInfo := extractFileInfo(existingFileData) - - if newInfo.staged != existingInfo.staged || - newInfo.additions != existingInfo.additions || - newInfo.deletions != existingInfo.deletions || - newInfo.diff != existingInfo.diff { - return false - } - } - - return true -} - -// fileInfoCompare holds extracted file info fields for comparison -type fileInfoCompare struct { - staged bool - additions int - deletions int - diff string -} - -// extractFileInfo extracts comparable fields from a file info interface -func extractFileInfo(fileData interface{}) fileInfoCompare { - info := fileInfoCompare{} - if fileData == nil { - return info - } - if fileMap, ok := fileData.(map[string]interface{}); ok { - if staged, ok := fileMap["staged"].(bool); ok { - info.staged = staged - } - // Handle both int and float64 (JSON numbers are float64) - if additions, ok := fileMap["additions"].(float64); ok { - info.additions = int(additions) - } else if additions, ok := fileMap["additions"].(int); ok { - info.additions = additions - } - if deletions, ok := fileMap["deletions"].(float64); ok { - info.deletions = int(deletions) - } else if deletions, ok := fileMap["deletions"].(int); ok { - info.deletions = deletions - } - // Extract diff content for comparison - if diff, ok := fileMap["diff"].(string); ok { - info.diff = diff - } - } - return info -} - -// handleContextWindowUpdated handles context window updates and persists them to session metadata -func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.ContextWindowData) { - s.logger.Debug("handling context window update", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.TaskSessionID), - zap.Int64("size", data.ContextWindowSize), - zap.Int64("used", data.ContextWindowUsed)) - - if data.TaskSessionID == "" { - s.logger.Debug("missing session_id for context window update", - zap.String("task_id", data.TaskID)) - return - } - - session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID) - if err != nil { - s.logger.Debug("no task session for context window update", - zap.String("session_id", data.TaskSessionID), - zap.Error(err)) - return - } - - // Update session metadata with context window info - if session.Metadata == nil { - session.Metadata = make(map[string]interface{}) - } - contextWindowData := map[string]interface{}{ - "size": data.ContextWindowSize, - "used": data.ContextWindowUsed, - "remaining": data.ContextWindowRemaining, - "efficiency": data.ContextEfficiency, - } - session.Metadata["context_window"] = contextWindowData - - // Persist to database asynchronously - go func() { - if err := s.repo.UpdateTaskSession(context.Background(), session); err != nil { - s.logger.Error("failed to update session with context window", - zap.String("task_id", data.TaskID), - zap.String("session_id", session.ID), - zap.Error(err)) - } else { - s.logger.Debug("persisted context window to session", - zap.String("task_id", data.TaskID), - zap.String("session_id", session.ID)) - } - }() - - // Broadcast session state change with metadata so frontend can update - // This uses the existing session.state_changed event with metadata included - if s.eventBus != nil { - _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( - events.TaskSessionStateChanged, - "orchestrator", - map[string]interface{}{ - "task_id": data.TaskID, - "session_id": session.ID, - "new_state": string(session.State), - "agent_profile_id": session.AgentProfileID, - "agent_profile_snapshot": session.AgentProfileSnapshot, - "metadata": map[string]interface{}{ - "context_window": contextWindowData, - }, - }, - )) - } -} - -// handlePermissionRequest handles permission request events and saves as message -func (s *Service) handlePermissionRequest(ctx context.Context, data watcher.PermissionRequestData) { - s.logger.Debug("handling permission request", - zap.String("task_id", data.TaskID), - zap.String("pending_id", data.PendingID), - zap.String("title", data.Title)) - - if data.TaskSessionID == "" { - s.logger.Warn("missing session_id for permission_request", - zap.String("task_id", data.TaskID), - zap.String("pending_id", data.PendingID)) - return - } - - s.setSessionWaitingForInput(ctx, data.TaskID, data.TaskSessionID) - - if s.messageCreator != nil { - _, err := s.messageCreator.CreatePermissionRequestMessage( - ctx, - data.TaskID, - data.TaskSessionID, - data.PendingID, - data.ToolCallID, - data.Title, - s.getActiveTurnID(data.TaskSessionID), - data.Options, - data.ActionType, - data.ActionDetails, - ) - if err != nil { - s.logger.Error("failed to create permission request message", - zap.String("task_id", data.TaskID), - zap.String("pending_id", data.PendingID), - zap.Error(err)) - } else { - s.logger.Debug("created permission request message", - zap.String("task_id", data.TaskID), - zap.String("pending_id", data.PendingID)) - } - } -} - -// handleGitCommitCreated handles git commit events by creating session commit records -func (s *Service) handleGitCommitCreated(ctx context.Context, data watcher.GitEventData) { - if data.Commit == nil { - s.logger.Debug("missing commit data for git commit event", - zap.String("task_id", data.TaskID)) - return - } - - s.logger.Debug("handling git commit created", - zap.String("task_id", data.TaskID), - zap.String("commit_sha", data.Commit.CommitSHA)) - - // Parse committed_at timestamp - var committedAt time.Time - if data.Commit.CommittedAt != "" { - if t, err := time.Parse(time.RFC3339, data.Commit.CommittedAt); err == nil { - committedAt = t - } else { - committedAt = time.Now().UTC() - } - } else { - committedAt = time.Now().UTC() - } - - sessionID := data.SessionID - taskID := data.TaskID - commitSHA := data.Commit.CommitSHA - - // Create session commit record - commit := &models.SessionCommit{ - SessionID: sessionID, - CommitSHA: data.Commit.CommitSHA, - ParentSHA: data.Commit.ParentSHA, - AuthorName: data.Commit.AuthorName, - AuthorEmail: data.Commit.AuthorEmail, - CommitMessage: data.Commit.Message, - CommittedAt: committedAt, - FilesChanged: data.Commit.FilesChanged, - Insertions: data.Commit.Insertions, - Deletions: data.Commit.Deletions, - } - - // Persist commit record to database asynchronously - go func() { - bgCtx := context.Background() - if err := s.repo.CreateSessionCommit(bgCtx, commit); err != nil { - s.logger.Error("failed to create session commit record", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("commit_sha", commitSHA), - zap.Error(err)) - } else { - s.logger.Debug("created session commit record", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("commit_sha", commitSHA)) - - // Publish event to notify frontend using unified format - if s.eventBus != nil { - event := bus.NewEvent(events.GitEvent, "orchestrator", &lifecycle.GitEventPayload{ - Type: lifecycle.GitEventTypeCommitCreated, - SessionID: sessionID, - TaskID: taskID, - Timestamp: time.Now().Format("2006-01-02T15:04:05.000000000Z07:00"), - Commit: &lifecycle.GitCommitData{ - ID: commit.ID, - CommitSHA: commit.CommitSHA, - ParentSHA: commit.ParentSHA, - Message: commit.CommitMessage, - AuthorName: commit.AuthorName, - AuthorEmail: commit.AuthorEmail, - FilesChanged: commit.FilesChanged, - Insertions: commit.Insertions, - Deletions: commit.Deletions, - CommittedAt: commit.CommittedAt.Format(time.RFC3339), - CreatedAt: commit.CreatedAt.Format("2006-01-02T15:04:05.000000000Z07:00"), - }, - }) - _ = s.eventBus.Publish(bgCtx, events.BuildGitWSEventSubject(sessionID), event) - } - } - }() -} - -// handleGitCommitsReset handles git reset events by removing orphaned commits -func (s *Service) handleGitCommitsReset(ctx context.Context, data watcher.GitEventData) { - if data.Reset == nil { - s.logger.Debug("missing reset data for git reset event", - zap.String("task_id", data.TaskID)) - return - } - - sessionID := data.SessionID - taskID := data.TaskID - previousHead := data.Reset.PreviousHead - currentHead := data.Reset.CurrentHead - - s.logger.Info("handling git commits reset", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("previous_head", previousHead), - zap.String("current_head", currentHead)) - - // Remove orphaned commits asynchronously - go func() { - bgCtx := context.Background() - - // Get all stored commits for this session - commits, err := s.repo.GetSessionCommits(bgCtx, sessionID) - if err != nil { - s.logger.Error("failed to get session commits for reset handling", - zap.String("session_id", sessionID), - zap.Error(err)) - return - } - - if len(commits) == 0 { - return - } - - // Build a map for quick lookup - commitBySHA := make(map[string]*models.SessionCommit) - for _, c := range commits { - commitBySHA[c.CommitSHA] = c - } - - // Walk the parent chain from currentHead to find reachable commits - reachable := make(map[string]bool) - current := currentHead - for current != "" { - reachable[current] = true - if c, exists := commitBySHA[current]; exists { - current = c.ParentSHA - } else { - // Parent not in our stored commits, stop walking - break - } - } - - // Delete commits that are not reachable from currentHead - var deleted int - for _, c := range commits { - if !reachable[c.CommitSHA] { - if err := s.repo.DeleteSessionCommit(bgCtx, c.ID); err != nil { - s.logger.Error("failed to delete orphaned commit", - zap.String("session_id", sessionID), - zap.String("commit_sha", c.CommitSHA), - zap.Error(err)) - } else { - deleted++ - s.logger.Debug("deleted orphaned commit after reset", - zap.String("session_id", sessionID), - zap.String("commit_sha", c.CommitSHA)) - } - } - } - - if deleted > 0 { - s.logger.Info("removed orphaned commits after git reset", - zap.String("session_id", sessionID), - zap.Int("deleted_count", deleted), - zap.String("new_head", currentHead)) - - // Publish event to notify frontend that commits changed - if s.eventBus != nil { - event := bus.NewEvent(events.GitEvent, "orchestrator", &lifecycle.GitEventPayload{ - Type: lifecycle.GitEventTypeCommitsReset, - SessionID: sessionID, - TaskID: taskID, - Timestamp: time.Now().Format("2006-01-02T15:04:05.000000000Z07:00"), - Reset: &lifecycle.GitResetData{ - PreviousHead: previousHead, - CurrentHead: currentHead, - DeletedCount: deleted, - }, - }) - _ = s.eventBus.Publish(bgCtx, events.BuildGitWSEventSubject(sessionID), event) - } - } - }() -} diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go new file mode 100644 index 0000000000..1ba2d205f6 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -0,0 +1,377 @@ +package orchestrator + +import ( + "context" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// handleAgentRunning handles agent running events (user sent input in passthrough mode) +// This is called when the user sends input to the agent, indicating a new turn started. +func (s *Service) handleAgentRunning(ctx context.Context, data watcher.AgentEventData) { + if data.SessionID == "" { + s.logger.Warn("missing session_id for agent running event", + zap.String("task_id", data.TaskID)) + return + } + + // Process on_turn_start workflow events (step transitions). + // For ACP sessions this happens in the message handler before PromptTask; + // for passthrough it happens here when the PTY detects user input. + s.processOnTurnStart(ctx, data.TaskID, data.SessionID) + + // Move session to running and task to in progress + s.setSessionRunning(ctx, data.TaskID, data.SessionID) +} + +// publishQueueStatusEvent publishes a queue status changed event for the given session +func (s *Service) publishQueueStatusEvent(ctx context.Context, sessionID string) { + if s.eventBus == nil { + return + } + + queueStatus := s.messageQueue.GetStatus(ctx, sessionID) + eventData := map[string]interface{}{ + "session_id": sessionID, + "is_queued": queueStatus.IsQueued, + "message": queueStatus.Message, + } + + s.logger.Debug("publishing queue status changed event", + zap.String("session_id", sessionID), + zap.Bool("is_queued", queueStatus.IsQueued)) + + _ = s.eventBus.Publish(ctx, events.MessageQueueStatusChanged, bus.NewEvent( + events.MessageQueueStatusChanged, + "orchestrator", + eventData, + )) +} + +// handleAgentReady handles agent ready events (turn complete in passthrough mode) +// This is called when the agent finishes processing and is waiting for input. +func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventData) { + if data.SessionID == "" { + s.logger.Warn("missing session_id for agent ready event", + zap.String("task_id", data.TaskID)) + return + } + + // Complete the current turn + s.completeTurnForSession(ctx, data.SessionID) + + // Check for workflow transition based on session's current step + // This handles the case where the agent finishes a step and should move to the next + transitioned := s.processOnTurnComplete(ctx, data.TaskID, data.SessionID) + + // If no workflow transition occurred, move session to waiting for input and task to review + if !transitioned { + s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID) + } + + // ALWAYS check for queued messages after agent becomes ready, regardless of workflow transition + queueStatus := s.messageQueue.GetStatus(ctx, data.SessionID) + s.logger.Info("checking for queued messages", + zap.String("session_id", data.SessionID), + zap.Bool("is_queued", queueStatus.IsQueued), + zap.Any("message", queueStatus.Message)) + + // Skip queued messages for passthrough sessions — PromptTask uses ACP which + // doesn't work for passthrough, and writing to PTY stdin is unreliable. + // Check before TakeQueued to avoid destructively removing the message. + if s.agentManager.IsPassthroughSession(ctx, data.SessionID) { + s.logger.Info("skipping queued message for passthrough session", + zap.String("session_id", data.SessionID)) + return + } + + queuedMsg, exists := s.messageQueue.TakeQueued(ctx, data.SessionID) + if !exists { + s.logger.Debug("no queued message to execute", + zap.String("session_id", data.SessionID)) + return + } + + // Skip if the queued message has empty content (might have been cleared accidentally) + if queuedMsg.Content == "" && len(queuedMsg.Attachments) == 0 { + s.logger.Warn("skipping empty queued message", + zap.String("session_id", data.SessionID), + zap.String("queue_id", queuedMsg.ID)) + + // Still publish status change to clear frontend state + s.publishQueueStatusEvent(ctx, data.SessionID) + return + } + + s.logger.Info("auto-executing queued message", + zap.String("session_id", data.SessionID), + zap.String("task_id", queuedMsg.TaskID), + zap.String("queue_id", queuedMsg.ID)) + + // Publish queue status changed event to notify frontend + s.publishQueueStatusEvent(ctx, data.SessionID) + + // Execute the queued message asynchronously + go s.executeQueuedMessage(data.SessionID, queuedMsg) +} + +func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messagequeue.QueuedMessage) { + promptCtx := context.Background() // Use a fresh context for async execution + + // Create user message for the queued message (so it appears in chat history) + if s.messageCreator != nil { + turnID := s.getActiveTurnID(queuedMsg.SessionID) + if turnID == "" { + // Start a new turn if needed + s.startTurnForSession(promptCtx, queuedMsg.SessionID) + turnID = s.getActiveTurnID(queuedMsg.SessionID) + } + + // Note: Attachments will be sent to the agent via PromptTask but not stored in the message + // This matches the behavior of direct prompts + meta := NewUserMessageMeta().WithPlanMode(queuedMsg.PlanMode) + err := s.messageCreator.CreateUserMessage(promptCtx, queuedMsg.TaskID, queuedMsg.Content, queuedMsg.SessionID, turnID, meta.ToMap()) + if err != nil { + s.logger.Error("failed to create user message for queued message", + zap.String("session_id", queuedMsg.SessionID), + zap.Error(err)) + // Continue anyway - the prompt should still be sent + } + } + + // Convert queue attachments to v1 attachments + attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments)) + for i, att := range queuedMsg.Attachments { + attachments[i] = v1.MessageAttachment{ + Type: att.Type, + Data: att.Data, + MimeType: att.MimeType, + } + } + + _, err := s.PromptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID, + queuedMsg.Content, queuedMsg.Model, queuedMsg.PlanMode, attachments) + if err != nil { + s.logger.Error("failed to execute queued message", + zap.String("session_id", callerSessionID), + zap.String("task_id", queuedMsg.TaskID), + zap.String("queue_id", queuedMsg.ID), + zap.Error(err)) + + // TODO: Implement dead letter queue for failed queued messages + // Currently, failed messages are lost. Consider: + // 1. Retry mechanism with exponential backoff + // 2. Persist failed messages to database for manual intervention + // 3. Notification to user about failed queue execution + s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)", + zap.String("session_id", callerSessionID), + zap.String("queue_id", queuedMsg.ID), + zap.String("content_preview", queuedMsg.Content[:min(50, len(queuedMsg.Content))])) + } +} + +// handleAgentCompleted handles agent completion events +func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) { + s.logger.Info("handling agent completed", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.String("agent_execution_id", data.AgentExecutionID)) + + // Update scheduler and remove from queue + s.scheduler.HandleTaskCompleted(data.TaskID, true) + s.scheduler.RemoveTask(data.TaskID) + + // Check for workflow transition based on session's current step + transitioned := s.processOnTurnComplete(ctx, data.TaskID, data.SessionID) + + // If no workflow transition occurred, move task to REVIEW state for user review + if !transitioned { + if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil { + s.logger.Error("failed to update task state to REVIEW", + zap.String("task_id", data.TaskID), + zap.Error(err)) + } else { + s.logger.Info("task moved to REVIEW state after agent completion", + zap.String("task_id", data.TaskID)) + } + } +} + +// handleAgentFailed handles agent failure events +func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) { + s.logger.Warn("handling agent failed", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.String("agent_execution_id", data.AgentExecutionID), + zap.String("error_message", data.ErrorMessage)) + + // Check if this was a resume failure (agent session no longer exists on the CLI side). + // If so, clear the resume token and re-launch a fresh session instead of failing. + // We do this BEFORE setting the session state to FAILED so the re-launch can proceed cleanly. + if data.SessionID != "" && isResumeFailure(data.ErrorMessage) { + if s.handleResumeFailure(ctx, data) { + return // Successfully re-launched without resume + } + // Fall through to normal failure handling if re-launch failed + } + + // Update session state to FAILED + if data.SessionID != "" { + s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateFailed, data.ErrorMessage, false) + } + + // Trigger retry logic + s.scheduler.HandleTaskCompleted(data.TaskID, false) + if !s.scheduler.RetryTask(data.TaskID) { + s.logger.Error("task failed and retry limit exceeded", + zap.String("task_id", data.TaskID)) + + // Move task to REVIEW state even on failure - user can decide to retry or close + // This maintains the review cycle: user reviews the failure and decides next steps + if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil { + s.logger.Error("failed to update task state to REVIEW after failure", + zap.String("task_id", data.TaskID), + zap.Error(err)) + } else { + s.logger.Info("task moved to REVIEW state after failure (for user review)", + zap.String("task_id", data.TaskID)) + } + } else { + // If retry is triggered, also move task to REVIEW state + // The retry will start a new agent when the task is re-launched + if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil { + s.logger.Error("failed to update task state to REVIEW after failure (with retry pending)", + zap.String("task_id", data.TaskID), + zap.Error(err)) + } + } +} + +// isResumeFailure checks if the error message indicates a failed --resume attempt +// (e.g., the Claude Code conversation was deleted or expired). +func isResumeFailure(errorMsg string) bool { + lower := strings.ToLower(errorMsg) + return strings.Contains(lower, "no conversation found") +} + +// handleResumeFailure handles the case where an agent failed because --resume pointed +// to a non-existent conversation. It clears the resume token, sends a status message, +// and schedules an asynchronous re-launch of the session without resume. +// +// The re-launch must be async because this handler is called synchronously from +// MarkCompleted → PublishAgentEvent, BEFORE RemoveExecution cleans up the failed +// execution from the manager's store. Calling ResumeSession synchronously would +// fail with ErrExecutionAlreadyRunning. +// +// Returns true to signal that the caller should skip normal failure handling +// (scheduler retry, FAILED state) since we're handling the retry ourselves. +func (s *Service) handleResumeFailure(ctx context.Context, data watcher.AgentEventData) bool { + s.logger.Warn("detected resume failure, retrying without resume", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.String("error", data.ErrorMessage)) + + // 1. Clear the resume token so the next attempt won't use --resume. + // Note: executor.ResumeSession already upserts a new ExecutorRunning without ResumeToken, + // but clear it explicitly to be safe against race conditions. + running, err := s.repo.GetExecutorRunningBySessionID(ctx, data.SessionID) + if err == nil && running != nil && running.ResumeToken != "" { + running.ResumeToken = "" + if upsertErr := s.repo.UpsertExecutorRunning(ctx, running); upsertErr != nil { + s.logger.Error("failed to clear resume token", + zap.String("session_id", data.SessionID), + zap.Error(upsertErr)) + } + } + + // 2. Send a status message about the failed resume + if s.messageCreator != nil { + statusMsg := fmt.Sprintf("Session resume failed: %s. Starting a fresh session...", data.ErrorMessage) + if err := s.messageCreator.CreateSessionMessage( + ctx, + data.TaskID, + statusMsg, + data.SessionID, + string(v1.MessageTypeStatus), + s.getActiveTurnID(data.SessionID), + map[string]interface{}{ + "variant": "warning", + "resume_failed": true, + }, + false, + ); err != nil { + s.logger.Warn("failed to create resume failure status message", + zap.String("task_id", data.TaskID), + zap.Error(err)) + } + } + + // 3. Schedule async re-launch. We use a goroutine because this handler runs + // synchronously inside the MarkCompleted → PublishAgentEvent call chain, + // before RemoveExecution clears the old execution from the manager's store. + // A synchronous ResumeSession call would fail with ErrExecutionAlreadyRunning. + taskID := data.TaskID + sessionID := data.SessionID + go func() { + // Brief delay to let RemoveExecution complete after event handler returns + time.Sleep(500 * time.Millisecond) + bgCtx := context.Background() + + session, err := s.repo.GetTaskSession(bgCtx, sessionID) + if err != nil { + s.logger.Error("failed to get session for resume retry", + zap.String("session_id", sessionID), + zap.Error(err)) + _ = s.repo.UpdateTaskSessionState(bgCtx, sessionID, models.TaskSessionStateFailed, "re-launch failed: "+err.Error()) + return + } + + _, err = s.executor.ResumeSession(bgCtx, session, true) + if err != nil { + s.logger.Error("failed to re-launch session after resume failure", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + _ = s.repo.UpdateTaskSessionState(bgCtx, sessionID, models.TaskSessionStateFailed, "re-launch failed: "+err.Error()) + return + } + + s.logger.Info("session re-launched after resume failure (fresh session without --resume)", + zap.String("task_id", taskID), + zap.String("session_id", sessionID)) + }() + + return true +} + +// handleAgentStopped handles agent stopped events (manual stop or cancellation) +func (s *Service) handleAgentStopped(ctx context.Context, data watcher.AgentEventData) { + s.logger.Info("handling agent stopped", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.String("agent_execution_id", data.AgentExecutionID)) + + // Complete the current turn if there is one + s.completeTurnForSession(ctx, data.SessionID) + + // Update session state to cancelled (already done by executor, but ensure consistency) + s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateCancelled, "", false) + + // NOTE: We do NOT update task state here because: + // 1. If this is from CompleteTask(), the task state will be set to COMPLETED by the caller + // 2. If this is from StopTask(), the task state should be set to REVIEW by the caller + // 3. Updating here would create a race condition with the caller's state update + // + // The task state management is the responsibility of the operation that triggered the stop, + // not the event handler. This handler only manages session-level cleanup. +} diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go new file mode 100644 index 0000000000..572aa150d6 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -0,0 +1,526 @@ +package orchestrator + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/models" +) + +// handleGitEvent handles unified git events and dispatches to appropriate handler +func (s *Service) handleGitEvent(ctx context.Context, data watcher.GitEventData) { + s.logger.Debug("handling git event", + zap.String("type", string(data.Type)), + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID)) + + if data.SessionID == "" { + s.logger.Debug("missing session_id for git event", + zap.String("task_id", data.TaskID), + zap.String("type", string(data.Type))) + return + } + + switch data.Type { + case lifecycle.GitEventTypeStatusUpdate: + s.handleGitStatusUpdate(ctx, data) + case lifecycle.GitEventTypeCommitCreated: + s.handleGitCommitCreated(ctx, data) + case lifecycle.GitEventTypeCommitsReset: + s.handleGitCommitsReset(ctx, data) + case lifecycle.GitEventTypeSnapshotCreated: + // Snapshot events are published from orchestrator, no need to handle here + s.logger.Debug("received snapshot_created event, no action needed", + zap.String("session_id", data.SessionID)) + default: + s.logger.Warn("unknown git event type", + zap.String("type", string(data.Type)), + zap.String("session_id", data.SessionID)) + } +} + +// handleGitStatusUpdate handles git status updates by creating git snapshots +func (s *Service) handleGitStatusUpdate(ctx context.Context, data watcher.GitEventData) { + if data.Status == nil { + s.logger.Debug("missing status data for git status update", + zap.String("task_id", data.TaskID)) + return + } + + // Forward status_update event to WebSocket subject for frontend + // Since data is already lifecycle.GitEventPayload, we can forward it directly + if s.eventBus != nil { + event := bus.NewEvent(events.GitWSEvent, "orchestrator", &data) + _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) + } + + // Convert Files from interface{} to map[string]interface{} + var files map[string]interface{} + if data.Status.Files != nil { + if f, ok := data.Status.Files.(map[string]interface{}); ok { + files = f + } + } + + // Create git snapshot instead of storing in session metadata + snapshot := &models.GitSnapshot{ + SessionID: data.SessionID, + SnapshotType: models.SnapshotTypeStatusUpdate, + Branch: data.Status.Branch, + RemoteBranch: data.Status.RemoteBranch, + HeadCommit: data.Status.HeadCommit, + BaseCommit: data.Status.BaseCommit, + Ahead: data.Status.Ahead, + Behind: data.Status.Behind, + Files: files, + TriggeredBy: "git_status_event", + Metadata: map[string]interface{}{ + "modified": data.Status.Modified, + "added": data.Status.Added, + "deleted": data.Status.Deleted, + "untracked": data.Status.Untracked, + "renamed": data.Status.Renamed, + "timestamp": data.Timestamp, + }, + } + + go s.persistGitSnapshot(data.SessionID, data.TaskID, snapshot) +} + +func (s *Service) persistGitSnapshot(sessionID, taskID string, snapshot *models.GitSnapshot) { + bgCtx := context.Background() + + // Check if this is a duplicate of the latest snapshot + latest, err := s.repo.GetLatestGitSnapshot(bgCtx, sessionID) + if err == nil && latest != nil { + // Compare key fields to detect duplicates + if s.isSnapshotDuplicate(latest, snapshot) { + s.logger.Debug("skipping duplicate git snapshot", + zap.String("task_id", taskID), + zap.String("session_id", sessionID)) + return + } + } + + if err := s.repo.CreateGitSnapshot(bgCtx, snapshot); err != nil { + s.logger.Error("failed to create git snapshot", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + + s.logger.Debug("created git snapshot", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("snapshot_id", snapshot.ID)) + + if s.eventBus == nil { + return + } + + event := bus.NewEvent(events.GitEvent, "orchestrator", &lifecycle.GitEventPayload{ + Type: lifecycle.GitEventTypeSnapshotCreated, + SessionID: sessionID, + TaskID: taskID, + Timestamp: snapshot.CreatedAt.Format("2006-01-02T15:04:05.000000000Z07:00"), + Snapshot: &lifecycle.GitSnapshotData{ + ID: snapshot.ID, + SessionID: snapshot.SessionID, + SnapshotType: string(snapshot.SnapshotType), + Branch: snapshot.Branch, + RemoteBranch: snapshot.RemoteBranch, + HeadCommit: snapshot.HeadCommit, + BaseCommit: snapshot.BaseCommit, + Ahead: snapshot.Ahead, + Behind: snapshot.Behind, + Files: snapshot.Files, + TriggeredBy: snapshot.TriggeredBy, + CreatedAt: snapshot.CreatedAt.Format("2006-01-02T15:04:05.000000000Z07:00"), + }, + }) + _ = s.eventBus.Publish(bgCtx, events.BuildGitWSEventSubject(sessionID), event) +} + +// isSnapshotDuplicate checks if two snapshots have the same content +func (s *Service) isSnapshotDuplicate(existing, new *models.GitSnapshot) bool { + // Different snapshot types are never duplicates + if existing.SnapshotType != new.SnapshotType { + return false + } + + // Compare branch and commit info + if existing.Branch != new.Branch || + existing.HeadCommit != new.HeadCommit || + existing.Ahead != new.Ahead || + existing.Behind != new.Behind { + return false + } + + // Compare file counts first (quick check) + existingFileCount := len(existing.Files) + newFileCount := len(new.Files) + if existingFileCount != newFileCount { + return false + } + + // Compare file paths, staged status, line counts, and diff content + for path, newFileData := range new.Files { + existingFileData, exists := existing.Files[path] + if !exists { + return false + } + + // Compare file details - extract from interface{} + newInfo := extractFileInfo(newFileData) + existingInfo := extractFileInfo(existingFileData) + + if newInfo.staged != existingInfo.staged || + newInfo.additions != existingInfo.additions || + newInfo.deletions != existingInfo.deletions || + newInfo.diff != existingInfo.diff { + return false + } + } + + return true +} + +// fileInfoCompare holds extracted file info fields for comparison +type fileInfoCompare struct { + staged bool + additions int + deletions int + diff string +} + +// extractFileInfo extracts comparable fields from a file info interface +func extractFileInfo(fileData interface{}) fileInfoCompare { + if fileData == nil { + return fileInfoCompare{} + } + fileMap, ok := fileData.(map[string]interface{}) + if !ok { + return fileInfoCompare{} + } + return extractFileInfoFromMap(fileMap) +} + +// extractFileInfoFromMap populates a fileInfoCompare from a string-keyed map. +func extractFileInfoFromMap(fileMap map[string]interface{}) fileInfoCompare { + info := fileInfoCompare{} + if staged, ok := fileMap["staged"].(bool); ok { + info.staged = staged + } + // Handle both int and float64 (JSON numbers are float64) + if additions, ok := fileMap["additions"].(float64); ok { + info.additions = int(additions) + } else if additions, ok := fileMap["additions"].(int); ok { + info.additions = additions + } + if deletions, ok := fileMap["deletions"].(float64); ok { + info.deletions = int(deletions) + } else if deletions, ok := fileMap["deletions"].(int); ok { + info.deletions = deletions + } + if diff, ok := fileMap["diff"].(string); ok { + info.diff = diff + } + return info +} + +// handleContextWindowUpdated handles context window updates and persists them to session metadata +func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.ContextWindowData) { + s.logger.Debug("handling context window update", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.TaskSessionID), + zap.Int64("size", data.ContextWindowSize), + zap.Int64("used", data.ContextWindowUsed)) + + if data.TaskSessionID == "" { + s.logger.Debug("missing session_id for context window update", + zap.String("task_id", data.TaskID)) + return + } + + session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID) + if err != nil { + s.logger.Debug("no task session for context window update", + zap.String("session_id", data.TaskSessionID), + zap.Error(err)) + return + } + + // Update session metadata with context window info + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + contextWindowData := map[string]interface{}{ + "size": data.ContextWindowSize, + "used": data.ContextWindowUsed, + "remaining": data.ContextWindowRemaining, + "efficiency": data.ContextEfficiency, + } + session.Metadata["context_window"] = contextWindowData + + // Persist to database asynchronously + go func() { + if err := s.repo.UpdateTaskSession(context.Background(), session); err != nil { + s.logger.Error("failed to update session with context window", + zap.String("task_id", data.TaskID), + zap.String("session_id", session.ID), + zap.Error(err)) + } else { + s.logger.Debug("persisted context window to session", + zap.String("task_id", data.TaskID), + zap.String("session_id", session.ID)) + } + }() + + // Broadcast session state change with metadata so frontend can update + // This uses the existing session.state_changed event with metadata included + if s.eventBus != nil { + _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( + events.TaskSessionStateChanged, + "orchestrator", + map[string]interface{}{ + "task_id": data.TaskID, + "session_id": session.ID, + "new_state": string(session.State), + "agent_profile_id": session.AgentProfileID, + "agent_profile_snapshot": session.AgentProfileSnapshot, + "metadata": map[string]interface{}{ + "context_window": contextWindowData, + }, + }, + )) + } +} + +// handlePermissionRequest handles permission request events and saves as message +func (s *Service) handlePermissionRequest(ctx context.Context, data watcher.PermissionRequestData) { + s.logger.Debug("handling permission request", + zap.String("task_id", data.TaskID), + zap.String("pending_id", data.PendingID), + zap.String("title", data.Title)) + + if data.TaskSessionID == "" { + s.logger.Warn("missing session_id for permission_request", + zap.String("task_id", data.TaskID), + zap.String("pending_id", data.PendingID)) + return + } + + s.setSessionWaitingForInput(ctx, data.TaskID, data.TaskSessionID) + + if s.messageCreator != nil { + _, err := s.messageCreator.CreatePermissionRequestMessage( + ctx, + data.TaskID, + data.TaskSessionID, + data.PendingID, + data.ToolCallID, + data.Title, + s.getActiveTurnID(data.TaskSessionID), + data.Options, + data.ActionType, + data.ActionDetails, + ) + if err != nil { + s.logger.Error("failed to create permission request message", + zap.String("task_id", data.TaskID), + zap.String("pending_id", data.PendingID), + zap.Error(err)) + } else { + s.logger.Debug("created permission request message", + zap.String("task_id", data.TaskID), + zap.String("pending_id", data.PendingID)) + } + } +} + +// handleGitCommitCreated handles git commit events by creating session commit records +func (s *Service) handleGitCommitCreated(ctx context.Context, data watcher.GitEventData) { + if data.Commit == nil { + s.logger.Debug("missing commit data for git commit event", + zap.String("task_id", data.TaskID)) + return + } + + s.logger.Debug("handling git commit created", + zap.String("task_id", data.TaskID), + zap.String("commit_sha", data.Commit.CommitSHA)) + + // Parse committed_at timestamp + var committedAt time.Time + if data.Commit.CommittedAt != "" { + if t, err := time.Parse(time.RFC3339, data.Commit.CommittedAt); err == nil { + committedAt = t + } else { + committedAt = time.Now().UTC() + } + } else { + committedAt = time.Now().UTC() + } + + sessionID := data.SessionID + taskID := data.TaskID + commitSHA := data.Commit.CommitSHA + + // Create session commit record + commit := &models.SessionCommit{ + SessionID: sessionID, + CommitSHA: data.Commit.CommitSHA, + ParentSHA: data.Commit.ParentSHA, + AuthorName: data.Commit.AuthorName, + AuthorEmail: data.Commit.AuthorEmail, + CommitMessage: data.Commit.Message, + CommittedAt: committedAt, + FilesChanged: data.Commit.FilesChanged, + Insertions: data.Commit.Insertions, + Deletions: data.Commit.Deletions, + } + + // Persist commit record to database asynchronously + go func() { + bgCtx := context.Background() + if err := s.repo.CreateSessionCommit(bgCtx, commit); err != nil { + s.logger.Error("failed to create session commit record", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("commit_sha", commitSHA), + zap.Error(err)) + } else { + s.logger.Debug("created session commit record", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("commit_sha", commitSHA)) + + // Publish event to notify frontend using unified format + if s.eventBus != nil { + event := bus.NewEvent(events.GitEvent, "orchestrator", &lifecycle.GitEventPayload{ + Type: lifecycle.GitEventTypeCommitCreated, + SessionID: sessionID, + TaskID: taskID, + Timestamp: time.Now().Format("2006-01-02T15:04:05.000000000Z07:00"), + Commit: &lifecycle.GitCommitData{ + ID: commit.ID, + CommitSHA: commit.CommitSHA, + ParentSHA: commit.ParentSHA, + Message: commit.CommitMessage, + AuthorName: commit.AuthorName, + AuthorEmail: commit.AuthorEmail, + FilesChanged: commit.FilesChanged, + Insertions: commit.Insertions, + Deletions: commit.Deletions, + CommittedAt: commit.CommittedAt.Format(time.RFC3339), + CreatedAt: commit.CreatedAt.Format("2006-01-02T15:04:05.000000000Z07:00"), + }, + }) + _ = s.eventBus.Publish(bgCtx, events.BuildGitWSEventSubject(sessionID), event) + } + } + }() +} + +// handleGitCommitsReset handles git reset events by removing orphaned commits +func (s *Service) handleGitCommitsReset(ctx context.Context, data watcher.GitEventData) { + if data.Reset == nil { + s.logger.Debug("missing reset data for git reset event", + zap.String("task_id", data.TaskID)) + return + } + + sessionID := data.SessionID + taskID := data.TaskID + previousHead := data.Reset.PreviousHead + currentHead := data.Reset.CurrentHead + + s.logger.Info("handling git commits reset", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("previous_head", previousHead), + zap.String("current_head", currentHead)) + + // Remove orphaned commits asynchronously + go s.pruneOrphanedCommits(sessionID, taskID, previousHead, currentHead) +} + +func (s *Service) pruneOrphanedCommits(sessionID, taskID, previousHead, currentHead string) { + bgCtx := context.Background() + + commits, err := s.repo.GetSessionCommits(bgCtx, sessionID) + if err != nil { + s.logger.Error("failed to get session commits for reset handling", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + if len(commits) == 0 { + return + } + + // Build a map for quick lookup + commitBySHA := make(map[string]*models.SessionCommit) + for _, c := range commits { + commitBySHA[c.CommitSHA] = c + } + + // Walk the parent chain from currentHead to find reachable commits + reachable := make(map[string]bool) + for cur := currentHead; cur != ""; { + reachable[cur] = true + if c, exists := commitBySHA[cur]; exists { + cur = c.ParentSHA + } else { + break + } + } + + // Delete commits that are not reachable from currentHead + var deleted int + for _, c := range commits { + if reachable[c.CommitSHA] { + continue + } + if err := s.repo.DeleteSessionCommit(bgCtx, c.ID); err != nil { + s.logger.Error("failed to delete orphaned commit", + zap.String("session_id", sessionID), + zap.String("commit_sha", c.CommitSHA), + zap.Error(err)) + } else { + deleted++ + s.logger.Debug("deleted orphaned commit after reset", + zap.String("session_id", sessionID), + zap.String("commit_sha", c.CommitSHA)) + } + } + + if deleted == 0 || s.eventBus == nil { + return + } + + s.logger.Info("removed orphaned commits after git reset", + zap.String("session_id", sessionID), + zap.Int("deleted_count", deleted), + zap.String("new_head", currentHead)) + + event := bus.NewEvent(events.GitEvent, "orchestrator", &lifecycle.GitEventPayload{ + Type: lifecycle.GitEventTypeCommitsReset, + SessionID: sessionID, + TaskID: taskID, + Timestamp: time.Now().Format("2006-01-02T15:04:05.000000000Z07:00"), + Reset: &lifecycle.GitResetData{ + PreviousHead: previousHead, + CurrentHead: currentHead, + DeletedCount: deleted, + }, + }) + _ = s.eventBus.Publish(bgCtx, events.BuildGitWSEventSubject(sessionID), event) +} diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go new file mode 100644 index 0000000000..8c49591f83 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -0,0 +1,467 @@ +package orchestrator + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// handleAgentStreamEvent handles agent stream events (tool calls, message chunks, etc.) +func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + if payload == nil || payload.Data == nil { + return + } + + taskID := payload.TaskID + sessionID := payload.SessionID + eventType := payload.Data.Type + + s.logger.Debug("handling agent stream event", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("event_type", eventType)) + + // Handle different event types + switch eventType { + case "message_streaming": + s.handleMessageStreamingEvent(ctx, payload) + + case "thinking_streaming": + s.handleThinkingStreamingEvent(ctx, payload) + + case "tool_call": + s.saveAgentTextIfPresent(ctx, payload) + s.handleToolCallEvent(ctx, payload) + + case "tool_update": + s.handleToolUpdateEvent(ctx, payload) + + case agentEventComplete: + s.handleCompleteStreamEvent(ctx, payload) + + case agentEventError: + s.handleAgentErrorEvent(ctx, payload) + + case "session_status": + s.handleSessionStatusEvent(ctx, payload) + + case "available_commands": + s.handleAvailableCommandsEvent(ctx, payload) + + case "permission_cancelled": + s.handlePermissionCancelledEvent(ctx, payload) + + case "log": + s.handleAgentLogEvent(ctx, payload) + } +} + +// handleAgentErrorEvent handles agentEventError events by creating an error message and completing the turn. +func (s *Service) handleAgentErrorEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + taskID := payload.TaskID + sessionID := payload.SessionID + if sessionID != "" && s.messageCreator != nil { + errorMsg := payload.Data.Error + if errorMsg == "" { + errorMsg = payload.Data.Text + } + if errorMsg == "" { + errorMsg = "An error occurred while processing your request" + } + metadata := map[string]interface{}{ + "provider": "agent", + "provider_agent": payload.AgentID, + } + if payload.Data.Data != nil { + metadata["error_data"] = payload.Data.Data + } + if err := s.messageCreator.CreateSessionMessage( + ctx, taskID, errorMsg, sessionID, + string(v1.MessageTypeError), s.getActiveTurnID(sessionID), metadata, false, + ); err != nil { + s.logger.Error("failed to create error message", + zap.String("task_id", taskID), + zap.Error(err)) + } + } + s.completeTurnForSession(ctx, sessionID) +} + +// handleSessionStatusEvent handles session_status events by storing resume token and creating a status message. +func (s *Service) handleSessionStatusEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + taskID := payload.TaskID + sessionID := payload.SessionID + if sessionID != "" && payload.Data.ACPSessionID != "" { + s.storeResumeToken(ctx, taskID, sessionID, payload.Data.ACPSessionID) + } + if sessionID == "" || s.messageCreator == nil { + return + } + statusMsg := "New session started" + if payload.Data.SessionStatus == "resumed" { + statusMsg = "Session resumed" + } + if err := s.messageCreator.CreateSessionMessage( + ctx, taskID, statusMsg, sessionID, + string(v1.MessageTypeStatus), s.getActiveTurnID(sessionID), nil, false, + ); err != nil { + s.logger.Error("failed to create session status message", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } +} + +// handleAgentLogEvent handles log events by storing agent log messages to the database. +func (s *Service) handleAgentLogEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + taskID := payload.TaskID + sessionID := payload.SessionID + if sessionID == "" || s.messageCreator == nil { + return + } + dataMap, _ := payload.Data.Data.(map[string]interface{}) + logMsg := payload.Data.Text + if logMsg == "" && dataMap != nil { + if msg, ok := dataMap["message"].(string); ok { + logMsg = msg + } + } + if logMsg == "" { + return + } + metadata := map[string]interface{}{ + "provider": "agent", + "provider_agent": payload.AgentID, + } + if dataMap != nil { + if level, ok := dataMap["level"].(string); ok { + metadata["level"] = level + } + for k, v := range dataMap { + if k != "message" && k != "level" { + metadata[k] = v + } + } + } + if err := s.messageCreator.CreateSessionMessage( + ctx, taskID, logMsg, sessionID, + string(v1.MessageTypeLog), s.getActiveTurnID(sessionID), metadata, false, + ); err != nil { + s.logger.Error("failed to create log message", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } else { + level := "unknown" + if l, ok := metadata["level"].(string); ok { + level = l + } + s.logger.Debug("created log message", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("level", level)) + } +} + +// handleToolCallEvent handles tool_call events and creates messages +func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + if payload.SessionID == "" { + s.logger.Warn("missing session_id for tool_call", + zap.String("task_id", payload.TaskID), + zap.String("tool_call_id", payload.Data.ToolCallID)) + return + } + + if s.messageCreator != nil { + if err := s.messageCreator.CreateToolCallMessage( + ctx, + payload.TaskID, + payload.Data.ToolCallID, + payload.Data.ParentToolCallID, // Pass parent for subagent nesting + payload.Data.ToolTitle, + payload.Data.ToolStatus, + payload.SessionID, + s.getActiveTurnID(payload.SessionID), + payload.Data.Normalized, // Pass normalized tool data for message metadata + ); err != nil { + s.logger.Error("failed to create tool call message", + zap.String("task_id", payload.TaskID), + zap.String("tool_call_id", payload.Data.ToolCallID), + zap.Error(err)) + } else { + s.logger.Debug("created tool call message", + zap.String("task_id", payload.TaskID), + zap.String("tool_call_id", payload.Data.ToolCallID)) + } + + // Allow tool calls to wake session from WAITING_FOR_INPUT + // This ensures that when user responds to a clarification and the agent continues, + // the session properly transitions to RUNNING + s.updateTaskSessionState(ctx, payload.TaskID, payload.SessionID, models.TaskSessionStateRunning, "", true) + } +} + +// saveAgentTextIfPresent saves any accumulated agent text as an agent message +func (s *Service) saveAgentTextIfPresent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + if payload.Data.Text == "" || payload.SessionID == "" { + return + } + + if s.messageCreator != nil { + if err := s.messageCreator.CreateAgentMessage(ctx, payload.TaskID, payload.Data.Text, payload.SessionID, s.getActiveTurnID(payload.SessionID)); err != nil { + s.logger.Error("failed to create agent message", + zap.String("task_id", payload.TaskID), + zap.Error(err)) + } else { + s.logger.Debug("created agent message", + zap.String("task_id", payload.TaskID), + zap.Int("message_length", len(payload.Data.Text))) + } + } +} + +// handleStreamingEventKind is the shared implementation for streaming message and thinking events. +// appendFn appends content to an existing message; createFn creates a new streaming message. +func (s *Service) handleStreamingEventKind( + ctx context.Context, + payload *lifecycle.AgentStreamEventPayload, + kind string, + appendFn func(context.Context, string, string) error, + createFn func(context.Context, string, string, string, string, string) error, +) { + if payload.Data.Text == "" || payload.SessionID == "" { + return + } + if s.messageCreator == nil { + return + } + messageID := payload.Data.MessageID + if messageID == "" { + s.logger.Warn("streaming "+kind+" event missing message ID", + zap.String("task_id", payload.TaskID), + zap.String("session_id", payload.SessionID)) + return + } + if payload.Data.IsAppend { + s.appendStreamingChunk(ctx, kind, messageID, payload.TaskID, payload.Data.Text, appendFn) + return + } + turnID := s.getActiveTurnID(payload.SessionID) + s.createStreamingChunk(ctx, kind, messageID, payload.TaskID, payload.Data.Text, payload.SessionID, turnID, createFn) +} + +// handleMessageStreamingEvent handles streaming message events for real-time text updates. +// It creates a new message on first chunk (IsAppend=false) or appends to existing (IsAppend=true). +func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + s.handleStreamingEventKind(ctx, payload, "message", + s.messageCreator.AppendAgentMessage, + s.messageCreator.CreateAgentMessageStreaming) +} + +// handleThinkingStreamingEvent handles streaming thinking events for real-time reasoning updates. +// It creates a new thinking message on first chunk (IsAppend=false) or appends to existing (IsAppend=true). +func (s *Service) handleThinkingStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + s.handleStreamingEventKind(ctx, payload, "thinking message", + s.messageCreator.AppendThinkingMessage, + s.messageCreator.CreateThinkingMessageStreaming) +} + +// handleToolUpdateEvent handles tool_update events and updates messages +func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + if payload.SessionID == "" { + s.logger.Warn("missing session_id for tool_update", + zap.String("task_id", payload.TaskID), + zap.String("tool_call_id", payload.Data.ToolCallID)) + return + } + + if s.messageCreator == nil { + return + } + + // Determine message type from normalized payload for fallback creation + msgType := toolKindToMessageType(payload.Data.Normalized) + + // Handle all status updates (running, complete, error) + switch payload.Data.ToolStatus { + case "running", agentEventComplete, "completed", "success", agentEventError, "failed": + if err := s.messageCreator.UpdateToolCallMessage( + ctx, + payload.TaskID, + payload.Data.ToolCallID, + payload.Data.ParentToolCallID, // Pass parent for subagent nesting + payload.Data.ToolStatus, + "", // result - no longer used, tool results in NormalizedPayload + payload.SessionID, + payload.Data.ToolTitle, // Include title from update event + s.getActiveTurnID(payload.SessionID), // Turn ID for fallback creation + msgType, // Message type for fallback creation + payload.Data.Normalized, // Pass normalized tool data for message metadata + ); err != nil { + s.logger.Warn("failed to update tool call message", + zap.String("task_id", payload.TaskID), + zap.String("tool_call_id", payload.Data.ToolCallID), + zap.Error(err)) + } + + // Update session state for completion events + // Allow tool completions to wake session from WAITING_FOR_INPUT + if payload.Data.ToolStatus == agentEventComplete || payload.Data.ToolStatus == "completed" || + payload.Data.ToolStatus == "success" || payload.Data.ToolStatus == agentEventError || payload.Data.ToolStatus == "failed" { + s.updateTaskSessionState(ctx, payload.TaskID, payload.SessionID, models.TaskSessionStateRunning, "", true) + } + } +} + +func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID string, nextState models.TaskSessionState, errorMessage string, allowWakeFromWaiting bool) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return + } + if session.State == models.TaskSessionStateWaitingForInput && nextState == models.TaskSessionStateRunning && !allowWakeFromWaiting { + return + } + oldState := session.State + switch session.State { + case models.TaskSessionStateCompleted, models.TaskSessionStateFailed, models.TaskSessionStateCancelled: + return + } + if session.State == nextState { + return + } + if err := s.repo.UpdateTaskSessionState(ctx, sessionID, nextState, errorMessage); err != nil { + s.logger.Error("failed to update task session state", + zap.String("session_id", sessionID), + zap.String("state", string(nextState)), + zap.Error(err)) + } + s.logger.Debug("task session state updated", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("old_state", string(oldState)), + zap.String("new_state", string(nextState))) + if s.eventBus != nil { + eventData := map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "old_state": string(oldState), + "new_state": string(nextState), + "agent_profile_id": session.AgentProfileID, + "agent_profile_snapshot": session.AgentProfileSnapshot, + "is_passthrough": session.IsPassthrough, + } + // Include review_status and workflow_step_id if present to ensure frontend state consistency + if session.ReviewStatus != nil { + eventData["review_status"] = *session.ReviewStatus + } + if session.WorkflowStepID != nil { + eventData["workflow_step_id"] = *session.WorkflowStepID + } + _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(events.TaskSessionStateChanged, "task-session", eventData)) + } +} + +func (s *Service) setSessionWaitingForInput(ctx context.Context, taskID, sessionID string) { + s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateWaitingForInput, "", false) + + if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil { + s.logger.Error("failed to update task state to REVIEW", + zap.String("task_id", taskID), + zap.Error(err)) + } else { + s.logger.Info("task moved to REVIEW state", + zap.String("task_id", taskID)) + } +} + +func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID string) { + s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateRunning, "", true) + + if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil { + s.logger.Error("failed to update task state to IN_PROGRESS", + zap.String("task_id", taskID), + zap.Error(err)) + } else { + s.logger.Info("task moved to IN_PROGRESS state", + zap.String("task_id", taskID)) + } +} + +// handleCompleteStreamEvent handles the agentEventComplete stream event. +func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + s.logger.Debug("orchestrator received complete event", + zap.String("task_id", payload.TaskID), + zap.String("session_id", payload.SessionID), + zap.Int("text_length", len(payload.Data.Text)), + zap.Bool("has_text", payload.Data.Text != "")) + s.saveAgentTextIfPresent(ctx, payload) + s.completeTurnForSession(ctx, payload.SessionID) + s.setSessionWaitingForInput(ctx, payload.TaskID, payload.SessionID) +} + +// handleAvailableCommandsEvent broadcasts available_commands events to the WebSocket for the frontend. +func (s *Service) handleAvailableCommandsEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + sessionID := payload.SessionID + if sessionID == "" || s.eventBus == nil || len(payload.Data.AvailableCommands) == 0 { + return + } + eventPayload := lifecycle.AvailableCommandsEventPayload{ + TaskID: payload.TaskID, + SessionID: sessionID, + AgentID: payload.AgentID, + AvailableCommands: payload.Data.AvailableCommands, + } + subject := events.BuildAvailableCommandsSubject(sessionID) + _ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.AvailableCommandsUpdated, "orchestrator", eventPayload)) +} + +// handlePermissionCancelledEvent marks the pending permission message as expired. +func (s *Service) handlePermissionCancelledEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + sessionID := payload.SessionID + if sessionID == "" || payload.Data.PendingID == "" || s.messageCreator == nil { + return + } + if err := s.messageCreator.UpdatePermissionMessage(ctx, sessionID, payload.Data.PendingID, "expired"); err != nil { + s.logger.Warn("failed to mark permission as expired", + zap.String("session_id", sessionID), + zap.String("pending_id", payload.Data.PendingID), + zap.Error(err)) + } +} + +// appendStreamingChunk appends a text chunk to an existing streaming message. +func (s *Service) appendStreamingChunk(ctx context.Context, kind, messageID, taskID, text string, appendFn func(context.Context, string, string) error) { + if err := appendFn(ctx, messageID, text); err != nil { + s.logger.Error("failed to append to streaming "+kind, + zap.String("task_id", taskID), + zap.String("message_id", messageID), + zap.Error(err)) + return + } + s.logger.Debug("appended to streaming "+kind, + zap.String("task_id", taskID), + zap.String("message_id", messageID), + zap.Int("content_length", len(text))) +} + +// createStreamingChunk creates a new streaming message for the first chunk. +func (s *Service) createStreamingChunk(ctx context.Context, kind, messageID, taskID, text, sessionID, turnID string, createFn func(context.Context, string, string, string, string, string) error) { + if err := createFn(ctx, messageID, taskID, text, sessionID, turnID); err != nil { + s.logger.Error("failed to create streaming "+kind, + zap.String("task_id", taskID), + zap.String("message_id", messageID), + zap.Error(err)) + return + } + s.logger.Debug("created streaming "+kind, + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("message_id", messageID), + zap.Int("content_length", len(text))) +} diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go new file mode 100644 index 0000000000..81f0b369d5 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -0,0 +1,496 @@ +package orchestrator + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// processOnTurnComplete processes the on_turn_complete events for the current step. +// Returns true if a transition occurred (step change happened). +func (s *Service) processOnTurnComplete(ctx context.Context, taskID, sessionID string) bool { + if sessionID == "" || s.workflowStepGetter == nil { + return false + } + + // Get the session to find its current workflow step + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to get session for workflow transition", + zap.String("session_id", sessionID), + zap.Error(err)) + return false + } + + if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { + s.logger.Debug("session has no workflow step, skipping transition", + zap.String("session_id", sessionID)) + return false + } + + workflowStepID := *session.WorkflowStepID + + // Get the current workflow step + currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) + if err != nil { + s.logger.Warn("failed to get workflow step for transition", + zap.String("workflow_step_id", workflowStepID), + zap.Error(err)) + return false + } + + // If no on_turn_complete actions, do nothing (manual step) + if len(currentStep.Events.OnTurnComplete) == 0 { + s.logger.Debug("step has no on_turn_complete actions, waiting for user", + zap.String("step_id", currentStep.ID), + zap.String("step_name", currentStep.Name)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return false + } + + // Process side-effect actions first, then find the first transition action + transitionAction := s.processTurnCompleteActions(ctx, sessionID, currentStep) + + // If no transition action found, just apply side effects and wait + if transitionAction == nil { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return false + } + + targetStepID, ok := s.resolveTransitionTargetStep(ctx, taskID, sessionID, currentStep, transitionAction) + if !ok { + return false + } + s.executeStepTransition(ctx, taskID, sessionID, currentStep, targetStepID, true) + return true +} + +func (s *Service) resolveTransitionTargetStep(ctx context.Context, taskID, sessionID string, currentStep *wfmodels.WorkflowStep, action *wfmodels.OnTurnCompleteAction) (string, bool) { + switch action.Type { + case wfmodels.OnTurnCompleteMoveToNext: + nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) + if err != nil { + s.logger.Warn("failed to get next step by position", + zap.String("workflow_id", currentStep.WorkflowID), + zap.Int("current_position", currentStep.Position), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return "", false + } + if nextStep == nil { + s.logger.Debug("no next step found (last step), staying", zap.String("step_name", currentStep.Name)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return "", false + } + return nextStep.ID, true + case wfmodels.OnTurnCompleteMoveToPrevious: + prevStep, err := s.workflowStepGetter.GetPreviousStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) + if err != nil { + s.logger.Warn("failed to get previous step by position", + zap.String("workflow_id", currentStep.WorkflowID), + zap.Int("current_position", currentStep.Position), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return "", false + } + if prevStep == nil { + s.logger.Debug("no previous step found (first step), staying", zap.String("step_name", currentStep.Name)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return "", false + } + return prevStep.ID, true + case wfmodels.OnTurnCompleteMoveToStep: + var targetStepID string + if action.Config != nil { + if sid, ok := action.Config["step_id"].(string); ok { + targetStepID = sid + } + } + if targetStepID == "" { + s.logger.Warn("move_to_step action missing step_id config", zap.String("step_id", currentStep.ID)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return "", false + } + return targetStepID, true + } + return "", false +} + +// processOnTurnStart processes the on_turn_start events for the current step. +// This is called when a user sends a message. Returns true if a transition occurred. +func (s *Service) processOnTurnStart(ctx context.Context, taskID, sessionID string) bool { + if sessionID == "" || s.workflowStepGetter == nil { + return false + } + + // Get the session to find its current workflow step + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to get session for on_turn_start", + zap.String("session_id", sessionID), + zap.Error(err)) + return false + } + + if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { + return false + } + + workflowStepID := *session.WorkflowStepID + + // Get the current workflow step + currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) + if err != nil { + s.logger.Warn("failed to get workflow step for on_turn_start", + zap.String("workflow_step_id", workflowStepID), + zap.Error(err)) + return false + } + + // If no on_turn_start actions, do nothing + if len(currentStep.Events.OnTurnStart) == 0 { + return false + } + + // Find the first transition action + var transitionAction *wfmodels.OnTurnStartAction + for i := range currentStep.Events.OnTurnStart { + action := ¤tStep.Events.OnTurnStart[i] + switch action.Type { + case wfmodels.OnTurnStartMoveToNext, wfmodels.OnTurnStartMoveToPrevious, wfmodels.OnTurnStartMoveToStep: + if transitionAction == nil { + transitionAction = action + } + } + } + + if transitionAction == nil { + return false + } + + // Resolve the target step ID + targetStepID, ok := s.resolveTurnStartTargetStep(ctx, currentStep, transitionAction) + if !ok { + return false + } + + s.logger.Info("on_turn_start triggered step transition", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("from_step", currentStep.Name), + zap.String("action", string(transitionAction.Type))) + + // Execute the step transition WITHOUT triggering on_enter auto-start + // (user is about to send a message, the prompt will come from them) + s.executeStepTransition(ctx, taskID, sessionID, currentStep, targetStepID, false) + return true +} + +// ProcessOnTurnStart is the public API for triggering on_turn_start events. +// Called by message handlers before sending a prompt to the agent. +func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { + s.processOnTurnStart(ctx, taskID, sessionID) + return nil +} + +// executeStepTransition moves a task/session from one step to another. +// If triggerOnEnter is true, on_enter actions (like auto_start_agent) are processed. +// If false, only the step change is applied (used for on_turn_start where the user is about to send a message). +func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID string, fromStep *wfmodels.WorkflowStep, toStepID string, triggerOnEnter bool) { + // Process on_exit actions for the step we're leaving (before the step change) + s.processOnExit(ctx, taskID, sessionID, fromStep) + + // Get the target step + targetStep, err := s.workflowStepGetter.GetStep(ctx, toStepID) + if err != nil { + s.logger.Warn("failed to get target workflow step", + zap.String("target_step_id", toStepID), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return + } + + // Get the task to update its workflow step + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + s.logger.Warn("failed to get task for workflow transition", + zap.String("task_id", taskID), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return + } + + // Update the task's workflow step + task.WorkflowStepID = toStepID + task.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to move task to next workflow step", + zap.String("task_id", taskID), + zap.String("from_step", fromStep.Name), + zap.String("to_step", targetStep.Name), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return + } + + // Publish task updated event + if s.eventBus != nil { + taskEventData := map[string]interface{}{ + "task_id": task.ID, + "workflow_id": task.WorkflowID, + "workflow_step_id": task.WorkflowStepID, + "title": task.Title, + "description": task.Description, + "state": string(task.State), + "priority": task.Priority, + "position": task.Position, + } + _ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent( + events.TaskUpdated, + "orchestrator", + taskEventData, + )) + } + + // Update session's workflow step + if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, toStepID); err != nil { + s.logger.Warn("failed to update session workflow step", + zap.String("session_id", sessionID), + zap.String("step_id", toStepID), + zap.Error(err)) + } + + // Clear review status when transitioning + if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { + s.logger.Warn("failed to clear session review status", + zap.String("session_id", sessionID), + zap.Error(err)) + } + + s.logger.Info("workflow transition completed", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("from_step", fromStep.Name), + zap.String("to_step", targetStep.Name), + zap.Bool("trigger_on_enter", triggerOnEnter)) + + // Process on_enter for the target step (skip if triggerOnEnter is false, + // e.g. on_turn_start transitions where the user is about to send a message) + if triggerOnEnter { + s.processOnEnter(ctx, taskID, sessionID, targetStep, task.Description) + } else { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + } +} + +// processOnEnter processes the on_enter events for a step after transitioning to it. +func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep, taskDescription string) { + isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) + + // Check if this step enables plan mode + hasPlanMode := false + for _, action := range step.Events.OnEnter { + if action.Type == wfmodels.OnEnterEnablePlanMode { + hasPlanMode = true + break + } + } + + // Skip plan mode management for passthrough sessions — the CLI manages its own state. + // For ACP sessions, auto-disable plan mode when entering a step that doesn't explicitly enable it. + if !isPassthrough { + if !hasPlanMode { + s.clearSessionPlanMode(ctx, sessionID) + } + } + + if len(step.Events.OnEnter) == 0 { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + + // Publish session state change + if s.eventBus != nil { + eventData := map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "workflow_step_id": step.ID, + "new_state": string(models.TaskSessionStateWaitingForInput), + } + _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( + events.TaskSessionStateChanged, + "orchestrator", + eventData, + )) + } + return + } + + hasAutoStart := false + for _, action := range step.Events.OnEnter { + switch action.Type { + case wfmodels.OnEnterEnablePlanMode: + // Skip plan mode for passthrough — CLI manages its own state + if !isPassthrough { + s.setSessionPlanMode(ctx, sessionID, true) + } + case wfmodels.OnEnterAutoStartAgent: + hasAutoStart = true + } + } + + // Skip auto-start for passthrough sessions — stdin is unreliable and the + // process may not be expecting input. Let the user interact directly. + if hasAutoStart && !isPassthrough { + // Build prompt from step configuration + effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID) + planMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) + + // Auto-start the agent via ACP prompt + go func() { + bgCtx := context.Background() + _, err := s.PromptTask(bgCtx, taskID, sessionID, effectivePrompt, "", planMode, nil) + if err != nil { + s.logger.Error("failed to auto-start agent for step", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("step_name", step.Name), + zap.Error(err)) + s.setSessionWaitingForInput(bgCtx, taskID, sessionID) + } + }() + } else { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + + // Publish session state change + if s.eventBus != nil { + eventData := map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "workflow_step_id": step.ID, + "new_state": string(models.TaskSessionStateWaitingForInput), + } + _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( + events.TaskSessionStateChanged, + "orchestrator", + eventData, + )) + } + } +} + +// processOnExit processes the on_exit events for a step when leaving it. +// This is called before transitioning to the next step. Only side-effect actions +// are supported (no transitions — those are decided by on_turn_complete). +func (s *Service) processOnExit(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep) { + if len(step.Events.OnExit) == 0 { + return + } + + // Skip plan mode management for passthrough sessions — the CLI manages its own state. + isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) + + for _, action := range step.Events.OnExit { + if action.Type == wfmodels.OnExitDisablePlanMode && !isPassthrough { + s.clearSessionPlanMode(ctx, sessionID) + s.logger.Debug("on_exit: disabled plan mode", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("step_name", step.Name)) + } + } +} + +// clearSessionPlanMode clears plan mode from session metadata. +func (s *Service) clearSessionPlanMode(ctx context.Context, sessionID string) { + s.setSessionPlanMode(ctx, sessionID, false) +} + +// setSessionPlanMode sets or clears plan mode in session metadata. +func (s *Service) setSessionPlanMode(ctx context.Context, sessionID string, enabled bool) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to get session for plan mode update", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + + if enabled { + session.Metadata["plan_mode"] = true + } else { + delete(session.Metadata, "plan_mode") + } + + if err := s.repo.UpdateTaskSession(ctx, session); err != nil { + s.logger.Warn("failed to update session plan mode", + zap.String("session_id", sessionID), + zap.Bool("enabled", enabled), + zap.Error(err)) + } +} + +// processTurnCompleteActions processes on_turn_complete actions for a step: +// it executes side-effect actions and returns the first eligible transition action. +func (s *Service) processTurnCompleteActions(ctx context.Context, sessionID string, step *wfmodels.WorkflowStep) *wfmodels.OnTurnCompleteAction { + var transitionAction *wfmodels.OnTurnCompleteAction + for i := range step.Events.OnTurnComplete { + action := &step.Events.OnTurnComplete[i] + switch action.Type { + case wfmodels.OnTurnCompleteDisablePlanMode: + s.clearSessionPlanMode(ctx, sessionID) + case wfmodels.OnTurnCompleteMoveToNext, wfmodels.OnTurnCompleteMoveToPrevious, wfmodels.OnTurnCompleteMoveToStep: + if requiresApproval(action.Config) { + continue + } + if transitionAction == nil { + transitionAction = action + } + } + } + return transitionAction +} + +// requiresApproval returns true if an action config has requires_approval set to true. +func requiresApproval(config map[string]interface{}) bool { + if config == nil { + return false + } + ra, ok := config["requires_approval"].(bool) + return ok && ra +} + +// resolveTurnStartTargetStep resolves the target step ID for an on_turn_start transition action. +// Returns the step ID and true if resolved; empty string and false if not resolvable. +func (s *Service) resolveTurnStartTargetStep(ctx context.Context, currentStep *wfmodels.WorkflowStep, action *wfmodels.OnTurnStartAction) (string, bool) { + switch action.Type { + case wfmodels.OnTurnStartMoveToNext: + next, err := s.workflowStepGetter.GetNextStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) + if err != nil || next == nil { + return "", false + } + return next.ID, true + case wfmodels.OnTurnStartMoveToPrevious: + prev, err := s.workflowStepGetter.GetPreviousStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) + if err != nil || prev == nil { + return "", false + } + return prev.ID, true + case wfmodels.OnTurnStartMoveToStep: + if action.Config != nil { + if sid, ok := action.Config["step_id"].(string); ok && sid != "" { + return sid, true + } + } + return "", false + } + return "", false +} diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 3a369b7277..b172cd6661 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -4,13 +4,9 @@ package executor import ( "context" "errors" - "fmt" - "strings" "sync" "time" - "github.com/google/uuid" - "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" @@ -19,6 +15,8 @@ import ( ) // Common errors +const defaultBaseBranch = "main" + var ( ErrNoAgentProfileID = errors.New("task has no agent_profile_id configured") ErrExecutionNotFound = errors.New("execution not found") @@ -212,1241 +210,3 @@ func (e *Executor) SetOnTaskStateChange(fn TaskStateChangeFunc) { e.onTaskStateChange = fn } -// startAgentProcessAsync starts the agent subprocess in a background goroutine. -// On success it transitions the task to IN_PROGRESS; on failure it marks both the -// session and task as FAILED. -func (e *Executor) startAgentProcessAsync(taskID, sessionID, agentExecutionID string) { - go func() { - startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - if err := e.agentManager.StartAgentProcess(startCtx, agentExecutionID); err != nil { - e.logger.Error("failed to start agent process", - zap.String("task_id", taskID), - zap.String("agent_execution_id", agentExecutionID), - zap.Error(err)) - if updateErr := e.repo.UpdateTaskSessionState(context.Background(), sessionID, models.TaskSessionStateFailed, err.Error()); updateErr != nil { - e.logger.Warn("failed to mark session as failed after start error", - zap.String("session_id", sessionID), - zap.Error(updateErr)) - } - if updateErr := e.updateTaskState(context.Background(), taskID, v1.TaskStateFailed); updateErr != nil { - e.logger.Warn("failed to mark task as failed after start error", - zap.String("task_id", taskID), - zap.Error(updateErr)) - } - return - } - - if updateErr := e.updateTaskState(context.Background(), taskID, v1.TaskStateInProgress); updateErr != nil { - e.logger.Warn("failed to update task state to IN_PROGRESS after agent start", - zap.String("task_id", taskID), - zap.Error(updateErr)) - } - }() -} - -// updateTaskState updates a task's state, using the callback if set for event publishing, -// or falling back to the raw repository. -func (e *Executor) updateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { - if e.onTaskStateChange != nil { - return e.onTaskStateChange(ctx, taskID, state) - } - return e.repo.UpdateTaskState(ctx, taskID, state) -} - -// shouldUseWorktree returns true if the given executor type should use Git worktrees. -func shouldUseWorktree(executorType string) bool { - return models.ExecutorType(executorType) == models.ExecutorTypeWorktree -} - -// repositoryCloneURL builds an HTTPS clone URL from the repository's provider info. -// Returns an empty string if the repository has no provider owner/name or if the -// provider is not recognized. -func repositoryCloneURL(repo *models.Repository) string { - if repo.ProviderOwner == "" || repo.ProviderName == "" { - return "" - } - var host string - switch strings.ToLower(repo.Provider) { - case "github", "": - host = "github.com" - case "gitlab": - host = "gitlab.com" - case "bitbucket": - host = "bitbucket.org" - default: - return "" - } - return fmt.Sprintf("https://%s/%s/%s.git", host, repo.ProviderOwner, repo.ProviderName) -} - -// getSessionLock returns a per-session mutex, creating one if it doesn't exist. -// This serializes concurrent resume/launch operations on the same session to prevent -// duplicate agent processes after backend restart. -func (e *Executor) getSessionLock(sessionID string) *sync.Mutex { - val, _ := e.sessionLocks.LoadOrStore(sessionID, &sync.Mutex{}) - return val.(*sync.Mutex) -} - -func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]string) map[string]string { - if e.shellPrefs == nil { - return env - } - preferred, err := e.shellPrefs.PreferredShell(ctx) - if err != nil { - return env - } - preferred = strings.TrimSpace(preferred) - if preferred == "" { - return env - } - if env == nil { - env = make(map[string]string) - } - env["AGENTCTL_SHELL_COMMAND"] = preferred - env["SHELL"] = preferred - return env -} - -// Execute starts agent execution for a task -func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) { - return e.ExecuteWithProfile(ctx, task, "", "", task.Description, "") -} - -// ExecuteWithProfile starts agent execution for a task using an explicit agent profile. -// The executorID parameter specifies which executor to use (determines runtime: local, worktree, local_docker, etc.). -// If executorID is empty, falls back to workspace's default executor. -// The prompt parameter is the initial prompt to send to the agent. -// The workflowStepID parameter associates the session with a workflow step for transitions. -func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, prompt string, workflowStepID string) (*TaskExecution, error) { - // Create session entry in database first - sessionID, err := e.PrepareSession(ctx, task, agentProfileID, executorID, workflowStepID) - if err != nil { - return nil, err - } - - // Launch the agent for the prepared session - return e.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, prompt, workflowStepID, true) -} - -// PrepareSession creates a session entry in the database without launching the agent. -// This allows the caller to get the session ID immediately and launch the agent later. -// Returns the session ID. -func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, workflowStepID string) (string, error) { - if agentProfileID == "" { - e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID)) - return "", ErrNoAgentProfileID - } - - metadata := cloneMetadata(task.Metadata) - var repositoryID string - var baseBranch string - - // Get the primary repository for this task - primaryTaskRepo, err := e.repo.GetPrimaryTaskRepository(ctx, task.ID) - if err != nil { - e.logger.Error("failed to get primary task repository", - zap.String("task_id", task.ID), - zap.Error(err)) - return "", err - } - - if primaryTaskRepo != nil { - repositoryID = primaryTaskRepo.RepositoryID - baseBranch = primaryTaskRepo.BaseBranch - } - - // Resolve agent profile to get model and other settings for snapshot - var agentProfileSnapshot map[string]interface{} - var isPassthrough bool - if profileInfo, err := e.agentManager.ResolveAgentProfile(ctx, agentProfileID); err == nil && profileInfo != nil { - agentProfileSnapshot = map[string]interface{}{ - "id": profileInfo.ProfileID, - "name": profileInfo.ProfileName, - "agent_id": profileInfo.AgentID, - "agent_name": profileInfo.AgentName, - "model": profileInfo.Model, - "auto_approve": profileInfo.AutoApprove, - "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, - "cli_passthrough": profileInfo.CLIPassthrough, - } - isPassthrough = profileInfo.CLIPassthrough - } else { - agentProfileSnapshot = map[string]interface{}{ - "id": agentProfileID, - "model": "", - } - } - - // Create agent session in database - sessionID := uuid.New().String() - now := time.Now().UTC() - session := &models.TaskSession{ - ID: sessionID, - TaskID: task.ID, - AgentProfileID: agentProfileID, - RepositoryID: repositoryID, - BaseBranch: baseBranch, - State: models.TaskSessionStateCreated, - StartedAt: now, - UpdatedAt: now, - AgentProfileSnapshot: agentProfileSnapshot, - IsPrimary: true, - IsPassthrough: isPassthrough, - } - if workflowStepID != "" { - session.WorkflowStepID = &workflowStepID - } - - // Resolve executor configuration - execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) - if execConfig.ExecutorID != "" { - session.ExecutorID = execConfig.ExecutorID - } - - if err := e.repo.CreateTaskSession(ctx, session); err != nil { - e.logger.Error("failed to persist agent session", - zap.String("task_id", task.ID), - zap.Error(err)) - return "", err - } - - // Clear primary flag on any other sessions for this task - if err := e.repo.SetSessionPrimary(ctx, sessionID); err != nil { - e.logger.Warn("failed to update primary session flag", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.Error(err)) - } - - e.logger.Info("session entry created", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID)) - - return sessionID, nil -} - -// LaunchPreparedSession launches the workspace (and optionally the agent) for a pre-created session. -// The session must have been created using PrepareSession. -// When startAgent is false, only the workspace infrastructure (agentctl) is launched; the agent -// subprocess is not started and the session state remains CREATED. -// When startAgent is true and the workspace was already launched (AgentExecutionID set), only the -// agent subprocess is started. -func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, sessionID string, agentProfileID string, executorID string, prompt string, workflowStepID string, startAgent bool) (*TaskExecution, error) { - // Fetch the session to get its configuration - session, err := e.repo.GetTaskSession(ctx, sessionID) - if err != nil { - e.logger.Error("failed to get session for launch", - zap.String("session_id", sessionID), - zap.Error(err)) - return nil, err - } - - if session.TaskID != task.ID { - return nil, fmt.Errorf("session does not belong to task") - } - - // Fast path: workspace already launched (e.g., from PrepareSession with workspace). - // Only start the agent subprocess if requested; otherwise return early. - if session.AgentExecutionID != "" { - return e.startAgentOnExistingWorkspace(ctx, task, session, prompt, startAgent) - } - - metadata := cloneMetadata(task.Metadata) - var repositoryPath string - var repositoryID string - var baseBranch string - var worktreeBranchPrefix string - var pullBeforeWorktree bool - var repository *models.Repository - - // Get the primary repository for this task - primaryTaskRepo, err := e.repo.GetPrimaryTaskRepository(ctx, task.ID) - if err != nil { - e.logger.Error("failed to get primary task repository", - zap.String("task_id", task.ID), - zap.Error(err)) - return nil, err - } - - if primaryTaskRepo != nil { - repositoryID = primaryTaskRepo.RepositoryID - baseBranch = primaryTaskRepo.BaseBranch - - if repositoryID != "" { - repository, err = e.repo.GetRepository(ctx, repositoryID) - if err != nil { - e.logger.Error("failed to get repository", - zap.String("repository_id", repositoryID), - zap.Error(err)) - return nil, err - } - repositoryPath = repository.LocalPath - worktreeBranchPrefix = repository.WorktreeBranchPrefix - pullBeforeWorktree = repository.PullBeforeWorktree - if baseBranch == "" && repository.DefaultBranch != "" { - baseBranch = repository.DefaultBranch - } - } - } - - req := &LaunchAgentRequest{ - TaskID: task.ID, - TaskTitle: task.Title, - AgentProfileID: agentProfileID, - TaskDescription: prompt, - Priority: task.Priority, - SessionID: sessionID, - } - - // Resolve executor configuration - execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) - if execConfig.ExecutorID != "" { - metadata = execConfig.Metadata - req.ExecutorType = execConfig.ExecutorType - req.ExecutorConfig = execConfig.ExecutorCfg - } - - if repositoryPath != "" { - req.UseWorktree = shouldUseWorktree(execConfig.ExecutorType) - req.RepositoryPath = repositoryPath - req.BaseBranch = baseBranch - req.WorktreeBranchPrefix = worktreeBranchPrefix - req.PullBeforeWorktree = pullBeforeWorktree - } - - // Remote Docker needs a clone URL since the remote host has no access to the local filesystem. - if models.ExecutorType(execConfig.ExecutorType) == models.ExecutorTypeRemoteDocker && repository != nil { - cloneURL := repositoryCloneURL(repository) - if cloneURL == "" { - return nil, ErrRemoteDockerNoRepoURL - } - req.RepositoryURL = cloneURL - } - - if len(metadata) > 0 { - req.Metadata = metadata - } - - e.logger.Info("launching agent for prepared session", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.String("agent_profile_id", agentProfileID), - zap.String("executor_type", req.ExecutorType), - zap.Bool("use_worktree", req.UseWorktree)) - - req.Env = e.applyPreferredShellEnv(ctx, req.Env) - - // Call the AgentManager to launch the container - resp, err := e.agentManager.LaunchAgent(ctx, req) - if err != nil { - e.logger.Error("failed to launch agent", - zap.String("task_id", task.ID), - zap.Error(err)) - if updateErr := e.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error()); updateErr != nil { - e.logger.Warn("failed to mark session as failed after launch error", - zap.String("session_id", sessionID), - zap.Error(updateErr)) - } - if updateErr := e.updateTaskState(ctx, task.ID, v1.TaskStateFailed); updateErr != nil { - e.logger.Warn("failed to mark task as failed after launch error", - zap.String("task_id", task.ID), - zap.Error(updateErr)) - } - return nil, err - } - - now := time.Now().UTC() - session.AgentExecutionID = resp.AgentExecutionID - session.ContainerID = resp.ContainerID - if startAgent { - session.State = models.TaskSessionStateStarting - } - session.ErrorMessage = "" - session.UpdatedAt = now - - if err := e.repo.UpdateTaskSession(ctx, session); err != nil { - e.logger.Error("failed to update agent session after launch", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.Error(err)) - } - - resumable := true - if session.ExecutorID != "" { - if executor, err := e.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil { - resumable = executor.Resumable - } - } - running := &models.ExecutorRunning{ - ID: session.ID, - SessionID: session.ID, - TaskID: task.ID, - ExecutorID: session.ExecutorID, - Status: "starting", - Resumable: resumable, - AgentExecutionID: resp.AgentExecutionID, - ContainerID: resp.ContainerID, - WorktreeID: resp.WorktreeID, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { - e.logger.Warn("failed to persist executor runtime after launch", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.Error(err)) - } - - if resp.WorktreeID != "" { - sessionWorktree := &models.TaskSessionWorktree{ - SessionID: session.ID, - WorktreeID: resp.WorktreeID, - RepositoryID: repositoryID, - Position: 0, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { - e.logger.Error("failed to persist session worktree association", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.String("worktree_id", resp.WorktreeID), - zap.Error(err)) - } - } - - sessionState := v1.TaskSessionStateCreated - if startAgent { - sessionState = v1.TaskSessionStateStarting - } - execution := &TaskExecution{ - TaskID: task.ID, - AgentExecutionID: resp.AgentExecutionID, - AgentProfileID: agentProfileID, - StartedAt: session.StartedAt, - SessionState: sessionState, - LastUpdate: now, - SessionID: sessionID, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - - if startAgent { - e.startAgentProcessAsync(task.ID, sessionID, resp.AgentExecutionID) - } - - e.logger.Info("agent launched for prepared session", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.String("agent_execution_id", resp.AgentExecutionID)) - - return execution, nil -} - -// startAgentOnExistingWorkspace handles the case where LaunchPreparedSession is called on a session -// whose workspace (agentctl) was already launched. It optionally starts just the agent subprocess. -func (e *Executor) startAgentOnExistingWorkspace(ctx context.Context, task *v1.Task, session *models.TaskSession, prompt string, startAgent bool) (*TaskExecution, error) { - if !startAgent { - // Workspace already launched, nothing else to do - now := time.Now().UTC() - return &TaskExecution{ - TaskID: task.ID, - AgentExecutionID: session.AgentExecutionID, - AgentProfileID: session.AgentProfileID, - StartedAt: session.StartedAt, - SessionState: v1.TaskSessionState(session.State), - LastUpdate: now, - SessionID: session.ID, - }, nil - } - - // Update the task description in the existing execution so StartAgentProcess picks it up - if prompt != "" { - if err := e.agentManager.SetExecutionDescription(ctx, session.AgentExecutionID, prompt); err != nil { - e.logger.Warn("failed to set execution description for existing workspace", - zap.String("session_id", session.ID), - zap.String("agent_execution_id", session.AgentExecutionID), - zap.Error(err)) - // Non-fatal: agent may start without description - } - } - - // Transition session to STARTING - now := time.Now().UTC() - session.State = models.TaskSessionStateStarting - session.ErrorMessage = "" - session.UpdatedAt = now - if err := e.repo.UpdateTaskSession(ctx, session); err != nil { - e.logger.Error("failed to update session state for agent start", - zap.String("session_id", session.ID), - zap.Error(err)) - } - - execution := &TaskExecution{ - TaskID: task.ID, - AgentExecutionID: session.AgentExecutionID, - AgentProfileID: session.AgentProfileID, - StartedAt: now, - SessionState: v1.TaskSessionStateStarting, - LastUpdate: now, - SessionID: session.ID, - } - - // Start the agent process asynchronously - e.startAgentProcessAsync(task.ID, session.ID, session.AgentExecutionID) - - e.logger.Info("agent starting on existing workspace", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.String("agent_execution_id", session.AgentExecutionID)) - - return execution, nil -} - -// ResumeSession restarts an existing task session using its stored worktree. -// When startAgent is false, only the executor runtime is started (agent process is not launched). -func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSession, startAgent bool) (*TaskExecution, error) { - if session == nil { - return nil, ErrExecutionNotFound - } - - // Acquire per-session lock to prevent concurrent resume/launch operations. - // This is critical after backend restart when multiple resume requests may arrive - // simultaneously (e.g., frontend auto-resume hook firing on page open). - sessionLock := e.getSessionLock(session.ID) - sessionLock.Lock() - defer sessionLock.Unlock() - - taskModel, err := e.repo.GetTask(ctx, session.TaskID) - if err != nil { - e.logger.Error("failed to load task for session resume", - zap.String("task_id", session.TaskID), - zap.String("session_id", session.ID), - zap.Error(err)) - return nil, err - } - task := taskModel.ToAPI() - if task == nil { - return nil, ErrExecutionNotFound - } - - if session.AgentProfileID == "" { - e.logger.Error("task session has no agent_profile_id configured", - zap.String("task_id", session.TaskID), - zap.String("session_id", session.ID)) - return nil, ErrNoAgentProfileID - } - - // Check if this specific session is already running - if existing, ok := e.GetExecutionBySession(session.ID); ok && existing != nil { - return nil, ErrExecutionAlreadyRunning - } - - req := &LaunchAgentRequest{ - TaskID: task.ID, - SessionID: session.ID, - TaskTitle: task.Title, - AgentProfileID: session.AgentProfileID, - TaskDescription: task.Description, - Priority: task.Priority, - } - - metadata := map[string]interface{}{} - if session.Metadata != nil { - for key, value := range session.Metadata { - metadata[key] = value - } - } - if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" { - metadata["worktree_id"] = session.Worktrees[0].WorktreeID - } - // Resolve executor configuration - executorWasEmpty := session.ExecutorID == "" - execConfig := e.resolveExecutorConfig(ctx, session.ExecutorID, task.WorkspaceID, metadata) - session.ExecutorID = execConfig.ExecutorID - metadata = execConfig.Metadata - req.ExecutorType = execConfig.ExecutorType - - // Persist executor assignment if it was resolved from workspace default - if executorWasEmpty && session.ExecutorID != "" { - session.UpdatedAt = time.Now().UTC() - if err := e.repo.UpdateTaskSession(ctx, session); err != nil { - e.logger.Warn("failed to persist executor assignment for session", - zap.String("session_id", session.ID), - zap.String("executor_id", session.ExecutorID), - zap.Error(err)) - // Continue anyway - this is not fatal - } - } - if len(metadata) > 0 { - req.Metadata = metadata - } - - repositoryID := session.RepositoryID - var repositoryPath string - var worktreeBranchPrefix string - var pullBeforeWorktree bool - var repository *models.Repository - if repositoryID == "" && len(task.Repositories) > 0 { - repositoryID = task.Repositories[0].RepositoryID - } - if repositoryID != "" { - repository, err = e.repo.GetRepository(ctx, repositoryID) - if err != nil { - e.logger.Error("failed to load repository for task session resume", - zap.String("task_id", task.ID), - zap.String("repository_id", repositoryID), - zap.Error(err)) - return nil, err - } - repositoryPath = repository.LocalPath - worktreeBranchPrefix = repository.WorktreeBranchPrefix - pullBeforeWorktree = repository.PullBeforeWorktree - if repositoryPath != "" { - req.RepositoryURL = repositoryPath - } - } - - baseBranch := session.BaseBranch - if baseBranch == "" && len(task.Repositories) > 0 && task.Repositories[0].BaseBranch != "" { - baseBranch = task.Repositories[0].BaseBranch - } - if baseBranch != "" { - req.Branch = baseBranch - } - - // Remote Docker needs a clone URL since the remote host has no access to the local filesystem. - if models.ExecutorType(req.ExecutorType) == models.ExecutorTypeRemoteDocker && repository != nil { - cloneURL := repositoryCloneURL(repository) - if cloneURL == "" { - return nil, ErrRemoteDockerNoRepoURL - } - req.RepositoryURL = cloneURL - } - - if shouldUseWorktree(req.ExecutorType) && repositoryPath != "" { - req.UseWorktree = true - req.RepositoryPath = repositoryPath - req.RepositoryID = repositoryID - if baseBranch != "" { - req.BaseBranch = baseBranch - } else { - req.BaseBranch = "main" - } - req.WorktreeBranchPrefix = worktreeBranchPrefix - req.PullBeforeWorktree = pullBeforeWorktree - } - - if running, err := e.repo.GetExecutorRunningBySessionID(ctx, session.ID); err == nil && running != nil { - if running.ResumeToken != "" && startAgent { - req.ACPSessionID = running.ResumeToken - // Clear TaskDescription so the agent doesn't receive an automatic prompt on resume. - // The session context is restored via ACP session/load; sending a prompt here would - // cause the agent to start working immediately instead of waiting for user input. - req.TaskDescription = "" - e.logger.Info("found resume token for session resumption", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID)) - } - } - - e.logger.Debug("resuming agent session", - zap.String("task_id", session.TaskID), - zap.String("session_id", session.ID), - zap.String("agent_profile_id", session.AgentProfileID), - zap.String("executor_type", req.ExecutorType), - zap.String("resume_token", req.ACPSessionID), - zap.Bool("use_worktree", req.UseWorktree)) - - req.Env = e.applyPreferredShellEnv(ctx, req.Env) - - resp, err := e.agentManager.LaunchAgent(ctx, req) - if err != nil { - e.logger.Error("failed to relaunch agent for session", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.Error(err)) - return nil, err - } - - session.AgentExecutionID = resp.AgentExecutionID - session.ContainerID = resp.ContainerID - session.ErrorMessage = "" - if startAgent { - session.State = models.TaskSessionStateStarting - session.CompletedAt = nil - } - - if err := e.repo.UpdateTaskSession(ctx, session); err != nil { - e.logger.Error("failed to update task session for resume", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.Error(err)) - } - - resumable := true - if session.ExecutorID != "" { - if executor, err := e.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil { - resumable = executor.Resumable - } - } - running := &models.ExecutorRunning{ - ID: session.ID, - SessionID: session.ID, - TaskID: task.ID, - ExecutorID: session.ExecutorID, - Status: "starting", - Resumable: resumable, - AgentExecutionID: resp.AgentExecutionID, - ContainerID: resp.ContainerID, - WorktreeID: resp.WorktreeID, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { - e.logger.Warn("failed to persist executor runtime after resume", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.Error(err)) - } - - if resp.WorktreeID != "" { - hasWorktree := false - for _, wt := range session.Worktrees { - if wt.WorktreeID == resp.WorktreeID { - hasWorktree = true - break - } - } - if !hasWorktree { - sessionWorktree := &models.TaskSessionWorktree{ - SessionID: session.ID, - WorktreeID: resp.WorktreeID, - RepositoryID: repositoryID, - Position: 0, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { - e.logger.Error("failed to persist session worktree association on resume", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.String("worktree_id", resp.WorktreeID), - zap.Error(err)) - } - } - } - - worktreePath := resp.WorktreePath - worktreeBranch := resp.WorktreeBranch - if worktreePath == "" && len(session.Worktrees) > 0 { - worktreePath = session.Worktrees[0].WorktreePath - worktreeBranch = session.Worktrees[0].WorktreeBranch - } - - now := time.Now().UTC() - execution := &TaskExecution{ - TaskID: task.ID, - AgentExecutionID: resp.AgentExecutionID, - AgentProfileID: session.AgentProfileID, - StartedAt: now, - SessionState: v1.TaskSessionStateStarting, - LastUpdate: now, - SessionID: session.ID, - WorktreePath: worktreePath, - WorktreeBranch: worktreeBranch, - } - - if startAgent { - // Start the agent process asynchronously - go func() { - startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - if err := e.agentManager.StartAgentProcess(startCtx, resp.AgentExecutionID); err != nil { - e.logger.Error("failed to start agent process on resume", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.String("agent_execution_id", resp.AgentExecutionID), - zap.Error(err)) - // Update session state to failed - if updateErr := e.repo.UpdateTaskSessionState(context.Background(), session.ID, models.TaskSessionStateFailed, err.Error()); updateErr != nil { - e.logger.Warn("failed to mark session as failed after start error on resume", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.Error(updateErr)) - } - if updateErr := e.updateTaskState(context.Background(), task.ID, v1.TaskStateFailed); updateErr != nil { - e.logger.Warn("failed to mark task as failed after start error on resume", - zap.String("task_id", task.ID), - zap.Error(updateErr)) - } - return - } - - // Agent resumed successfully - sync task state with session state. - // If the session is waiting for input, the task should be in REVIEW state. - // This ensures the task state reflects the actual agent status. - if session.State == models.TaskSessionStateWaitingForInput { - if updateErr := e.updateTaskState(context.Background(), task.ID, v1.TaskStateReview); updateErr != nil { - e.logger.Warn("failed to update task state to REVIEW after resume", - zap.String("task_id", task.ID), - zap.Error(updateErr)) - } else { - e.logger.Debug("task state synced to REVIEW after resume (session waiting for input)", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID)) - } - } else { - e.logger.Debug("agent resumed successfully, task state unchanged", - zap.String("task_id", task.ID), - zap.String("session_id", session.ID), - zap.String("session_state", string(session.State))) - } - }() - } - - return execution, nil -} - -// Stop stops an active execution by session ID -func (e *Executor) Stop(ctx context.Context, sessionID string, reason string, force bool) error { - session, err := e.repo.GetTaskSession(ctx, sessionID) - if err != nil { - return ErrExecutionNotFound - } - if session.AgentExecutionID == "" { - return ErrExecutionNotFound - } - - e.logger.Info("stopping execution", - zap.String("task_id", session.TaskID), - zap.String("session_id", sessionID), - zap.String("agent_execution_id", session.AgentExecutionID), - zap.String("reason", reason), - zap.Bool("force", force)) - - err = e.agentManager.StopAgent(ctx, session.AgentExecutionID, force) - if err != nil { - // Log the error but continue to clean up execution state - // The agent instance may already be gone (container stopped externally) - e.logger.Warn("failed to stop agent (may already be stopped)", - zap.String("session_id", sessionID), - zap.Error(err)) - } - - // Update database - if dbErr := e.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateCancelled, reason); dbErr != nil { - e.logger.Error("failed to update agent session status in database", - zap.String("session_id", sessionID), - zap.Error(dbErr)) - } - - return nil -} - -// StopByTaskID stops all active executions for a task -func (e *Executor) StopByTaskID(ctx context.Context, taskID string, reason string, force bool) error { - // Get all active sessions for this task from database - sessions, err := e.repo.ListActiveTaskSessionsByTaskID(ctx, taskID) - if err != nil { - e.logger.Warn("failed to list active sessions for task", - zap.String("task_id", taskID), - zap.Error(err)) - return ErrExecutionNotFound - } - - if len(sessions) == 0 { - return ErrExecutionNotFound - } - - var lastErr error - stoppedCount := 0 - for _, session := range sessions { - if err := e.Stop(ctx, session.ID, reason, force); err != nil { - e.logger.Warn("failed to stop session", - zap.String("task_id", taskID), - zap.String("session_id", session.ID), - zap.Error(err)) - lastErr = err - } else { - stoppedCount++ - } - } - - if stoppedCount == 0 && lastErr != nil { - return lastErr - } - - return nil -} - -// Prompt sends a follow-up prompt to a running agent for a task -// Returns PromptResult indicating if the agent needs input -// Attachments (images) are passed to the agent if provided -func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) { - session, err := e.repo.GetTaskSession(ctx, sessionID) - if err != nil { - return nil, ErrExecutionNotFound - } - if session.TaskID != taskID { - return nil, ErrExecutionNotFound - } - if session.AgentExecutionID == "" { - return nil, ErrExecutionNotFound - } - - e.logger.Debug("sending prompt to agent", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("agent_execution_id", session.AgentExecutionID), - zap.Int("prompt_length", len(prompt)), - zap.Int("attachments_count", len(attachments))) - - result, err := e.agentManager.PromptAgent(ctx, session.AgentExecutionID, prompt, attachments) - if err != nil { - if errors.Is(err, lifecycle.ErrExecutionNotFound) { - return nil, ErrExecutionNotFound - } - return nil, err - } - return result, nil -} - -// SwitchModel stops the current agent, restarts it with a new model, and sends the prompt. -// For agents that support session resume (can_recover: true), it attempts to resume context. -// For agents that don't support resume (can_recover: false), a fresh session is started. -func (e *Executor) SwitchModel(ctx context.Context, taskID, sessionID, newModel, prompt string) (*PromptResult, error) { - e.logger.Info("switching model for session", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("new_model", newModel)) - - // Get the session - session, err := e.repo.GetTaskSession(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("failed to get session: %w", err) - } - if session.TaskID != taskID { - return nil, fmt.Errorf("session %s does not belong to task %s", sessionID, taskID) - } - - // Get current execution ID - oldAgentExecutionID := session.AgentExecutionID - if oldAgentExecutionID == "" { - return nil, ErrExecutionNotFound - } - - // Get the task for re-launching - task, err := e.repo.GetTask(ctx, taskID) - if err != nil { - return nil, fmt.Errorf("failed to get task: %w", err) - } - - // Get executor running info to check for resume token (ACP session ID) - var acpSessionID string - if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { - acpSessionID = running.ResumeToken - } - - // Stop the current agent - e.logger.Info("stopping current agent for model switch", - zap.String("agent_execution_id", oldAgentExecutionID)) - if err := e.agentManager.StopAgent(ctx, oldAgentExecutionID, false); err != nil { - e.logger.Warn("failed to stop agent for model switch, continuing anyway", - zap.Error(err), - zap.String("agent_execution_id", oldAgentExecutionID)) - } - - // Resolve executor configuration - execConfig := e.resolveExecutorConfig(ctx, session.ExecutorID, task.WorkspaceID, nil) - - // Build a new launch request with the model override - req := &LaunchAgentRequest{ - TaskID: task.ID, - SessionID: sessionID, // Reuse the existing session ID - TaskTitle: task.Title, - AgentProfileID: session.AgentProfileID, - TaskDescription: prompt, - ModelOverride: newModel, // This is the key - use the new model - ACPSessionID: acpSessionID, - ExecutorType: execConfig.ExecutorType, - Metadata: execConfig.Metadata, - } - - // Get repository info if available - var repositoryPath string - if session.RepositoryID != "" { - repository, repoErr := e.repo.GetRepository(ctx, session.RepositoryID) - if repoErr == nil && repository != nil { - repositoryPath = repository.LocalPath - req.RepositoryURL = repository.LocalPath - req.Branch = session.BaseBranch - - // Remote Docker needs a clone URL instead of a local path. - if models.ExecutorType(execConfig.ExecutorType) == models.ExecutorTypeRemoteDocker { - cloneURL := repositoryCloneURL(repository) - if cloneURL == "" { - return nil, ErrRemoteDockerNoRepoURL - } - req.RepositoryURL = cloneURL - } - } - } - - // Configure worktree if executor type requires it - reuse existing worktree from session - if shouldUseWorktree(execConfig.ExecutorType) && repositoryPath != "" { - req.UseWorktree = true - req.RepositoryPath = repositoryPath - req.RepositoryID = session.RepositoryID - if session.BaseBranch != "" { - req.BaseBranch = session.BaseBranch - } else { - req.BaseBranch = "main" - } - // Pass existing worktree ID in metadata for reuse - if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" { - if req.Metadata == nil { - req.Metadata = make(map[string]interface{}) - } - req.Metadata["worktree_id"] = session.Worktrees[0].WorktreeID - } - } - - // Get worktree info from running state (for workspace path) - if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { - if running.WorktreePath != "" { - req.RepositoryURL = running.WorktreePath - } - } - - req.Env = e.applyPreferredShellEnv(ctx, req.Env) - - e.logger.Info("launching new agent with model override", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.String("model", newModel), - zap.String("executor_type", req.ExecutorType), - zap.String("acp_session_id", acpSessionID), - zap.Bool("use_worktree", req.UseWorktree), - zap.String("repository_path", req.RepositoryPath)) - - // Launch the new agent - resp, err := e.agentManager.LaunchAgent(ctx, req) - if err != nil { - e.logger.Error("failed to launch agent with new model", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.Error(err)) - return nil, fmt.Errorf("failed to launch agent with new model: %w", err) - } - - // Update the session with the new execution ID - session.AgentExecutionID = resp.AgentExecutionID - session.ContainerID = resp.ContainerID - session.State = models.TaskSessionStateStarting - session.UpdatedAt = time.Now().UTC() - - // Update the agent profile snapshot with the new model - if session.AgentProfileSnapshot == nil { - session.AgentProfileSnapshot = make(map[string]interface{}) - } - session.AgentProfileSnapshot["model"] = newModel - - if err := e.repo.UpdateTaskSession(ctx, session); err != nil { - e.logger.Error("failed to update session after model switch", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.Error(err)) - } - - // Update executor running state - if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { - running.AgentExecutionID = resp.AgentExecutionID - running.ContainerID = resp.ContainerID - running.Status = "starting" - if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { - e.logger.Warn("failed to update executor running after model switch", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.Error(err)) - } - } - - // Start the agent process (this also handles initialization and prompting) - if err := e.agentManager.StartAgentProcess(ctx, resp.AgentExecutionID); err != nil { - e.logger.Error("failed to start agent process after model switch", - zap.String("task_id", task.ID), - zap.String("agent_execution_id", resp.AgentExecutionID), - zap.Error(err)) - return nil, fmt.Errorf("failed to start agent after model switch: %w", err) - } - - e.logger.Info("model switch complete, agent started", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.String("new_model", newModel), - zap.String("agent_execution_id", resp.AgentExecutionID)) - - // The agent initialization and prompt are handled as part of StartAgentProcess - // Return success - the actual prompt response will come via ACP events - return &PromptResult{ - StopReason: "model_switched", - AgentMessage: "", - }, nil -} - -// RespondToPermission sends a response to a permission request for a session -func (e *Executor) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { - e.logger.Debug("responding to permission request", - zap.String("session_id", sessionID), - zap.String("pending_id", pendingID), - zap.String("option_id", optionID), - zap.Bool("cancelled", cancelled)) - - return e.agentManager.RespondToPermissionBySessionID(ctx, sessionID, pendingID, optionID, cancelled) -} - -// GetExecutionBySession returns the execution state for a specific session -func (e *Executor) GetExecutionBySession(sessionID string) (*TaskExecution, bool) { - ctx := context.Background() - const startupGracePeriod = 30 * time.Second - - // Load from database - session, err := e.repo.GetTaskSession(ctx, sessionID) - if err != nil { - return nil, false - } - if session.AgentExecutionID == "" { - return nil, false - } - - // Verify the agent is actually running - if !e.agentManager.IsAgentRunningForSession(ctx, sessionID) { - if (session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning) && - time.Since(session.UpdatedAt) < startupGracePeriod { - return FromTaskSession(session), true - } - return nil, false - } - - return FromTaskSession(session), true -} - -// ListExecutions returns all active executions -func (e *Executor) ListExecutions() []*TaskExecution { - ctx := context.Background() - sessions, err := e.repo.ListActiveTaskSessions(ctx) - if err != nil { - return nil - } - - result := make([]*TaskExecution, 0, len(sessions)) - for _, session := range sessions { - result = append(result, FromTaskSession(session)) - } - return result -} - -// ActiveCount returns the number of active executions -func (e *Executor) ActiveCount() int { - ctx := context.Background() - sessions, err := e.repo.ListActiveTaskSessions(ctx) - if err != nil { - return 0 - } - return len(sessions) -} - -// CanExecute returns true if there's capacity for another execution. -// Currently always returns true as there is no concurrent execution limit. -func (e *Executor) CanExecute() bool { - return true -} - -// MarkCompletedBySession marks an execution as completed by session ID -func (e *Executor) MarkCompletedBySession(ctx context.Context, sessionID string, state v1.TaskSessionState) { - e.logger.Info("execution completed", - zap.String("session_id", sessionID), - zap.String("state", string(state))) - - // Update database - dbState := models.TaskSessionState(state) - if err := e.repo.UpdateTaskSessionState(ctx, sessionID, dbState, ""); err != nil { - e.logger.Error("failed to update agent session status in database", - zap.String("session_id", sessionID), - zap.Error(err)) - } -} - -func (e *Executor) defaultExecutorID(ctx context.Context, workspaceID string) string { - if workspaceID == "" { - return "" - } - workspace, err := e.repo.GetWorkspace(ctx, workspaceID) - if err != nil || workspace == nil || workspace.DefaultExecutorID == nil { - return "" - } - return strings.TrimSpace(*workspace.DefaultExecutorID) -} - -// executorConfig holds resolved executor configuration. -type executorConfig struct { - ExecutorID string - ExecutorType string - ExecutorCfg map[string]string // The executor record's Config map (docker_host, etc.) - Metadata map[string]interface{} -} - -// resolveExecutorConfig resolves executor configuration from an executor ID. -// If executorID is empty, it falls back to the workspace default. -// Returns the resolved config with executor ID, type, and metadata. -func (e *Executor) resolveExecutorConfig(ctx context.Context, executorID, workspaceID string, existingMetadata map[string]interface{}) executorConfig { - resolved := executorID - if resolved == "" { - resolved = e.defaultExecutorID(ctx, workspaceID) - } - - metadata := existingMetadata - if metadata == nil { - metadata = make(map[string]interface{}) - } - - if resolved == "" { - return executorConfig{Metadata: metadata} - } - - metadata["executor_id"] = resolved - - executor, err := e.repo.GetExecutor(ctx, resolved) - if err != nil || executor == nil { - return executorConfig{ - ExecutorID: resolved, - Metadata: metadata, - } - } - - if policyJSON := strings.TrimSpace(executor.Config["mcp_policy"]); policyJSON != "" { - metadata["executor_mcp_policy"] = policyJSON - } - - return executorConfig{ - ExecutorID: resolved, - ExecutorType: string(executor.Type), - ExecutorCfg: executor.Config, - Metadata: metadata, - } -} - -func cloneMetadata(src map[string]interface{}) map[string]interface{} { - if len(src) == 0 { - return nil - } - out := make(map[string]interface{}, len(src)) - for key, value := range src { - out[key] = value - } - return out -} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go new file mode 100644 index 0000000000..33fba618dc --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -0,0 +1,441 @@ +package executor + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +// startAgentProcessAsync starts the agent subprocess in a background goroutine. +// On success it transitions the task to IN_PROGRESS; on failure it marks both the +// session and task as FAILED. +func (e *Executor) startAgentProcessAsync(taskID, sessionID, agentExecutionID string) { + go func() { + startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + if err := e.agentManager.StartAgentProcess(startCtx, agentExecutionID); err != nil { + e.logger.Error("failed to start agent process", + zap.String("task_id", taskID), + zap.String("agent_execution_id", agentExecutionID), + zap.Error(err)) + if updateErr := e.repo.UpdateTaskSessionState(context.Background(), sessionID, models.TaskSessionStateFailed, err.Error()); updateErr != nil { + e.logger.Warn("failed to mark session as failed after start error", + zap.String("session_id", sessionID), + zap.Error(updateErr)) + } + if updateErr := e.updateTaskState(context.Background(), taskID, v1.TaskStateFailed); updateErr != nil { + e.logger.Warn("failed to mark task as failed after start error", + zap.String("task_id", taskID), + zap.Error(updateErr)) + } + return + } + + if updateErr := e.updateTaskState(context.Background(), taskID, v1.TaskStateInProgress); updateErr != nil { + e.logger.Warn("failed to update task state to IN_PROGRESS after agent start", + zap.String("task_id", taskID), + zap.Error(updateErr)) + } + }() +} + +// updateTaskState updates a task's state, using the callback if set for event publishing, +// or falling back to the raw repository. +func (e *Executor) updateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { + if e.onTaskStateChange != nil { + return e.onTaskStateChange(ctx, taskID, state) + } + return e.repo.UpdateTaskState(ctx, taskID, state) +} + +// shouldUseWorktree returns true if the given executor type should use Git worktrees. +func shouldUseWorktree(executorType string) bool { + return models.ExecutorType(executorType) == models.ExecutorTypeWorktree +} + +// repositoryCloneURL builds an HTTPS clone URL from the repository's provider info. +// Returns an empty string if the repository has no provider owner/name or if the +// provider is not recognized. +func repositoryCloneURL(repo *models.Repository) string { + if repo.ProviderOwner == "" || repo.ProviderName == "" { + return "" + } + var host string + switch strings.ToLower(repo.Provider) { + case "github", "": + host = "github.com" + case "gitlab": + host = "gitlab.com" + case "bitbucket": + host = "bitbucket.org" + default: + return "" + } + return fmt.Sprintf("https://%s/%s/%s.git", host, repo.ProviderOwner, repo.ProviderName) +} + +// getSessionLock returns a per-session mutex, creating one if it doesn't exist. +// This serializes concurrent resume/launch operations on the same session to prevent +// duplicate agent processes after backend restart. +func (e *Executor) getSessionLock(sessionID string) *sync.Mutex { + val, _ := e.sessionLocks.LoadOrStore(sessionID, &sync.Mutex{}) + return val.(*sync.Mutex) +} + +func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]string) map[string]string { + if e.shellPrefs == nil { + return env + } + preferred, err := e.shellPrefs.PreferredShell(ctx) + if err != nil { + return env + } + preferred = strings.TrimSpace(preferred) + if preferred == "" { + return env + } + if env == nil { + env = make(map[string]string) + } + env["AGENTCTL_SHELL_COMMAND"] = preferred + env["SHELL"] = preferred + return env +} + +// Execute starts agent execution for a task +func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) { + return e.ExecuteWithProfile(ctx, task, "", "", task.Description, "") +} + +// ExecuteWithProfile starts agent execution for a task using an explicit agent profile. +// The executorID parameter specifies which executor to use (determines runtime: local, worktree, local_docker, etc.). +// If executorID is empty, falls back to workspace's default executor. +// The prompt parameter is the initial prompt to send to the agent. +// The workflowStepID parameter associates the session with a workflow step for transitions. +func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, prompt string, workflowStepID string) (*TaskExecution, error) { + // Create session entry in database first + sessionID, err := e.PrepareSession(ctx, task, agentProfileID, executorID, workflowStepID) + if err != nil { + return nil, err + } + + // Launch the agent for the prepared session + return e.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, prompt, workflowStepID, true) +} + +// PrepareSession creates a session entry in the database without launching the agent. +// This allows the caller to get the session ID immediately and launch the agent later. +// Returns the session ID. +func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, workflowStepID string) (string, error) { + if agentProfileID == "" { + e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID)) + return "", ErrNoAgentProfileID + } + + metadata := cloneMetadata(task.Metadata) + var repositoryID string + var baseBranch string + + // Get the primary repository for this task + primaryTaskRepo, err := e.repo.GetPrimaryTaskRepository(ctx, task.ID) + if err != nil { + e.logger.Error("failed to get primary task repository", + zap.String("task_id", task.ID), + zap.Error(err)) + return "", err + } + + if primaryTaskRepo != nil { + repositoryID = primaryTaskRepo.RepositoryID + baseBranch = primaryTaskRepo.BaseBranch + } + + // Resolve agent profile to get model and other settings for snapshot + agentProfileSnapshot, isPassthrough := e.resolveAgentProfileSnapshot(ctx, agentProfileID) + + // Create agent session in database + sessionID := uuid.New().String() + now := time.Now().UTC() + session := &models.TaskSession{ + ID: sessionID, + TaskID: task.ID, + AgentProfileID: agentProfileID, + RepositoryID: repositoryID, + BaseBranch: baseBranch, + State: models.TaskSessionStateCreated, + StartedAt: now, + UpdatedAt: now, + AgentProfileSnapshot: agentProfileSnapshot, + IsPrimary: true, + IsPassthrough: isPassthrough, + } + if workflowStepID != "" { + session.WorkflowStepID = &workflowStepID + } + + // Resolve executor configuration + execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) + if execConfig.ExecutorID != "" { + session.ExecutorID = execConfig.ExecutorID + } + + if err := e.repo.CreateTaskSession(ctx, session); err != nil { + e.logger.Error("failed to persist agent session", + zap.String("task_id", task.ID), + zap.Error(err)) + return "", err + } + + // Clear primary flag on any other sessions for this task + if err := e.repo.SetSessionPrimary(ctx, sessionID); err != nil { + e.logger.Warn("failed to update primary session flag", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + + e.logger.Info("session entry created", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID)) + + return sessionID, nil +} + +// resolveAgentProfileSnapshot resolves an agent profile ID to a snapshot map and passthrough flag. +func (e *Executor) resolveAgentProfileSnapshot(ctx context.Context, agentProfileID string) (map[string]interface{}, bool) { + profileInfo, err := e.agentManager.ResolveAgentProfile(ctx, agentProfileID) + if err != nil || profileInfo == nil { + return map[string]interface{}{ + "id": agentProfileID, + "model": "", + }, false + } + return map[string]interface{}{ + "id": profileInfo.ProfileID, + "name": profileInfo.ProfileName, + "agent_id": profileInfo.AgentID, + "agent_name": profileInfo.AgentName, + "model": profileInfo.Model, + "auto_approve": profileInfo.AutoApprove, + "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, + "cli_passthrough": profileInfo.CLIPassthrough, + }, profileInfo.CLIPassthrough +} + +// LaunchPreparedSession launches the workspace (and optionally the agent) for a pre-created session. +// The session must have been created using PrepareSession. +// When startAgent is false, only the workspace infrastructure (agentctl) is launched; the agent +// subprocess is not started and the session state remains CREATED. +// When startAgent is true and the workspace was already launched (AgentExecutionID set), only the +// agent subprocess is started. +func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, sessionID string, agentProfileID string, executorID string, prompt string, workflowStepID string, startAgent bool) (*TaskExecution, error) { + // Fetch the session to get its configuration + session, err := e.repo.GetTaskSession(ctx, sessionID) + if err != nil { + e.logger.Error("failed to get session for launch", + zap.String("session_id", sessionID), + zap.Error(err)) + return nil, err + } + + if session.TaskID != task.ID { + return nil, fmt.Errorf("session does not belong to task") + } + + // Fast path: workspace already launched (e.g., from PrepareSession with workspace). + // Only start the agent subprocess if requested; otherwise return early. + if session.AgentExecutionID != "" { + return e.startAgentOnExistingWorkspace(ctx, task, session, prompt, startAgent) + } + + repoInfo, err := e.resolvePrimaryRepoInfo(ctx, task.ID) + if err != nil { + return nil, err + } + + req, err := e.buildLaunchAgentRequest(ctx, task, sessionID, agentProfileID, executorID, prompt, repoInfo) + if err != nil { + return nil, err + } + + e.logger.Info("launching agent for prepared session", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.String("agent_profile_id", agentProfileID), + zap.String("executor_type", req.ExecutorType), + zap.Bool("use_worktree", req.UseWorktree)) + + req.Env = e.applyPreferredShellEnv(ctx, req.Env) + + // Call the AgentManager to launch the container + resp, err := e.agentManager.LaunchAgent(ctx, req) + if err != nil { + return nil, e.handleLaunchFailure(ctx, task.ID, sessionID, err) + } + + return e.finalizeLaunch(ctx, task, session, agentProfileID, sessionID, repoInfo, resp, startAgent) +} + +// handleLaunchFailure marks the session and task as FAILED and returns the original error. +func (e *Executor) handleLaunchFailure(ctx context.Context, taskID, sessionID string, launchErr error) error { + e.logger.Error("failed to launch agent", + zap.String("task_id", taskID), + zap.Error(launchErr)) + if updateErr := e.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, launchErr.Error()); updateErr != nil { + e.logger.Warn("failed to mark session as failed after launch error", + zap.String("session_id", sessionID), + zap.Error(updateErr)) + } + if updateErr := e.updateTaskState(ctx, taskID, v1.TaskStateFailed); updateErr != nil { + e.logger.Warn("failed to mark task as failed after launch error", + zap.String("task_id", taskID), + zap.Error(updateErr)) + } + return launchErr +} + +// finalizeLaunch persists launch state and returns the resulting TaskExecution. +func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, sessionID string, repoInfo *repoInfo, resp *LaunchAgentResponse, startAgent bool) (*TaskExecution, error) { + now := time.Now().UTC() + e.persistLaunchState(ctx, task.ID, sessionID, session, resp, startAgent, now) + e.persistWorktreeAssociation(ctx, task.ID, session.ID, repoInfo.RepositoryID, resp) + + sessionState := v1.TaskSessionStateCreated + if startAgent { + sessionState = v1.TaskSessionStateStarting + } + execution := &TaskExecution{ + TaskID: task.ID, + AgentExecutionID: resp.AgentExecutionID, + AgentProfileID: agentProfileID, + StartedAt: session.StartedAt, + SessionState: sessionState, + LastUpdate: now, + SessionID: sessionID, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + + if startAgent { + e.startAgentProcessAsync(task.ID, sessionID, resp.AgentExecutionID) + } + + e.logger.Info("agent launched for prepared session", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.String("agent_execution_id", resp.AgentExecutionID)) + + return execution, nil +} + +// buildLaunchAgentRequest constructs a LaunchAgentRequest for a new session launch, +// applying executor config, repository/worktree settings, and remote docker URL as needed. +func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, sessionID, agentProfileID, executorID, prompt string, repoInfo *repoInfo) (*LaunchAgentRequest, error) { + metadata := cloneMetadata(task.Metadata) + req := &LaunchAgentRequest{ + TaskID: task.ID, + TaskTitle: task.Title, + AgentProfileID: agentProfileID, + TaskDescription: prompt, + Priority: task.Priority, + SessionID: sessionID, + } + + execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) + if execConfig.ExecutorID != "" { + metadata = execConfig.Metadata + req.ExecutorType = execConfig.ExecutorType + req.ExecutorConfig = execConfig.ExecutorCfg + } + + if repoInfo.RepositoryPath != "" { + req.UseWorktree = shouldUseWorktree(execConfig.ExecutorType) + req.RepositoryPath = repoInfo.RepositoryPath + req.BaseBranch = repoInfo.BaseBranch + req.WorktreeBranchPrefix = repoInfo.WorktreeBranchPrefix + req.PullBeforeWorktree = repoInfo.PullBeforeWorktree + } + + // Remote Docker needs a clone URL since the remote host has no access to the local filesystem. + if models.ExecutorType(execConfig.ExecutorType) == models.ExecutorTypeRemoteDocker && repoInfo.Repository != nil { + cloneURL := repositoryCloneURL(repoInfo.Repository) + if cloneURL == "" { + return nil, ErrRemoteDockerNoRepoURL + } + req.RepositoryURL = cloneURL + } + + if len(metadata) > 0 { + req.Metadata = metadata + } + + return req, nil +} + +// startAgentOnExistingWorkspace handles the case where LaunchPreparedSession is called on a session +// whose workspace (agentctl) was already launched. It optionally starts just the agent subprocess. +func (e *Executor) startAgentOnExistingWorkspace(ctx context.Context, task *v1.Task, session *models.TaskSession, prompt string, startAgent bool) (*TaskExecution, error) { + if !startAgent { + // Workspace already launched, nothing else to do + now := time.Now().UTC() + return &TaskExecution{ + TaskID: task.ID, + AgentExecutionID: session.AgentExecutionID, + AgentProfileID: session.AgentProfileID, + StartedAt: session.StartedAt, + SessionState: v1.TaskSessionState(session.State), + LastUpdate: now, + SessionID: session.ID, + }, nil + } + + // Update the task description in the existing execution so StartAgentProcess picks it up + if prompt != "" { + if err := e.agentManager.SetExecutionDescription(ctx, session.AgentExecutionID, prompt); err != nil { + e.logger.Warn("failed to set execution description for existing workspace", + zap.String("session_id", session.ID), + zap.String("agent_execution_id", session.AgentExecutionID), + zap.Error(err)) + // Non-fatal: agent may start without description + } + } + + // Transition session to STARTING + now := time.Now().UTC() + session.State = models.TaskSessionStateStarting + session.ErrorMessage = "" + session.UpdatedAt = now + if err := e.repo.UpdateTaskSession(ctx, session); err != nil { + e.logger.Error("failed to update session state for agent start", + zap.String("session_id", session.ID), + zap.Error(err)) + } + + execution := &TaskExecution{ + TaskID: task.ID, + AgentExecutionID: session.AgentExecutionID, + AgentProfileID: session.AgentProfileID, + StartedAt: now, + SessionState: v1.TaskSessionStateStarting, + LastUpdate: now, + SessionID: session.ID, + } + + // Start the agent process asynchronously + e.startAgentProcessAsync(task.ID, session.ID, session.AgentExecutionID) + + e.logger.Info("agent starting on existing workspace", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID), + zap.String("agent_execution_id", session.AgentExecutionID)) + + return execution, nil +} diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go new file mode 100644 index 0000000000..de33688fb0 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -0,0 +1,343 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +// Stop stops an active execution by session ID +func (e *Executor) Stop(ctx context.Context, sessionID string, reason string, force bool) error { + session, err := e.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return ErrExecutionNotFound + } + if session.AgentExecutionID == "" { + return ErrExecutionNotFound + } + + e.logger.Info("stopping execution", + zap.String("task_id", session.TaskID), + zap.String("session_id", sessionID), + zap.String("agent_execution_id", session.AgentExecutionID), + zap.String("reason", reason), + zap.Bool("force", force)) + + err = e.agentManager.StopAgent(ctx, session.AgentExecutionID, force) + if err != nil { + // Log the error but continue to clean up execution state + // The agent instance may already be gone (container stopped externally) + e.logger.Warn("failed to stop agent (may already be stopped)", + zap.String("session_id", sessionID), + zap.Error(err)) + } + + // Update database + if dbErr := e.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateCancelled, reason); dbErr != nil { + e.logger.Error("failed to update agent session status in database", + zap.String("session_id", sessionID), + zap.Error(dbErr)) + } + + return nil +} + +// StopByTaskID stops all active executions for a task +func (e *Executor) StopByTaskID(ctx context.Context, taskID string, reason string, force bool) error { + // Get all active sessions for this task from database + sessions, err := e.repo.ListActiveTaskSessionsByTaskID(ctx, taskID) + if err != nil { + e.logger.Warn("failed to list active sessions for task", + zap.String("task_id", taskID), + zap.Error(err)) + return ErrExecutionNotFound + } + + if len(sessions) == 0 { + return ErrExecutionNotFound + } + + var lastErr error + stoppedCount := 0 + for _, session := range sessions { + if err := e.Stop(ctx, session.ID, reason, force); err != nil { + e.logger.Warn("failed to stop session", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + lastErr = err + } else { + stoppedCount++ + } + } + + if stoppedCount == 0 && lastErr != nil { + return lastErr + } + + return nil +} + +// Prompt sends a follow-up prompt to a running agent for a task +// Returns PromptResult indicating if the agent needs input +// Attachments (images) are passed to the agent if provided +func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) { + session, err := e.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, ErrExecutionNotFound + } + if session.TaskID != taskID { + return nil, ErrExecutionNotFound + } + if session.AgentExecutionID == "" { + return nil, ErrExecutionNotFound + } + + e.logger.Debug("sending prompt to agent", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("agent_execution_id", session.AgentExecutionID), + zap.Int("prompt_length", len(prompt)), + zap.Int("attachments_count", len(attachments))) + + result, err := e.agentManager.PromptAgent(ctx, session.AgentExecutionID, prompt, attachments) + if err != nil { + if errors.Is(err, lifecycle.ErrExecutionNotFound) { + return nil, ErrExecutionNotFound + } + return nil, err + } + return result, nil +} + +// SwitchModel stops the current agent, restarts it with a new model, and sends the prompt. +// For agents that support session resume (can_recover: true), it attempts to resume context. +// For agents that don't support resume (can_recover: false), a fresh session is started. +func (e *Executor) SwitchModel(ctx context.Context, taskID, sessionID, newModel, prompt string) (*PromptResult, error) { + e.logger.Info("switching model for session", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("new_model", newModel)) + + session, task, acpSessionID, err := e.prepareModelSwitch(ctx, taskID, sessionID) + if err != nil { + return nil, err + } + + execConfig := e.resolveExecutorConfig(ctx, session.ExecutorID, task.WorkspaceID, nil) + + req, err := e.buildSwitchModelRequest(ctx, task, session, sessionID, newModel, prompt, acpSessionID, execConfig) + if err != nil { + return nil, err + } + + req.Env = e.applyPreferredShellEnv(ctx, req.Env) + + e.logger.Info("launching new agent with model override", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.String("model", newModel), + zap.String("executor_type", req.ExecutorType), + zap.String("acp_session_id", acpSessionID), + zap.Bool("use_worktree", req.UseWorktree), + zap.String("repository_path", req.RepositoryPath)) + + if err := e.launchModelSwitchAgent(ctx, task.ID, sessionID, newModel, session, req); err != nil { + return nil, err + } + + // The agent initialization and prompt are handled as part of StartAgentProcess + // Return success - the actual prompt response will come via ACP events + return &PromptResult{ + StopReason: "model_switched", + AgentMessage: "", + }, nil +} + +// prepareModelSwitch validates the session/task and stops the current agent. +// Returns the session, task, ACP session ID, and any error. +func (e *Executor) prepareModelSwitch(ctx context.Context, taskID, sessionID string) (*models.TaskSession, *models.Task, string, error) { + session, err := e.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to get session: %w", err) + } + if session.TaskID != taskID { + return nil, nil, "", fmt.Errorf("session %s does not belong to task %s", sessionID, taskID) + } + if session.AgentExecutionID == "" { + return nil, nil, "", ErrExecutionNotFound + } + + task, err := e.repo.GetTask(ctx, taskID) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to get task: %w", err) + } + + var acpSessionID string + if running, runErr := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); runErr == nil && running != nil { + acpSessionID = running.ResumeToken + } + + e.logger.Info("stopping current agent for model switch", + zap.String("agent_execution_id", session.AgentExecutionID)) + if err := e.agentManager.StopAgent(ctx, session.AgentExecutionID, false); err != nil { + e.logger.Warn("failed to stop agent for model switch, continuing anyway", + zap.Error(err), + zap.String("agent_execution_id", session.AgentExecutionID)) + } + + return session, task, acpSessionID, nil +} + +// launchModelSwitchAgent launches the new agent, persists state, and starts the process. +func (e *Executor) launchModelSwitchAgent(ctx context.Context, taskID, sessionID, newModel string, session *models.TaskSession, req *LaunchAgentRequest) error { + resp, err := e.agentManager.LaunchAgent(ctx, req) + if err != nil { + e.logger.Error("failed to launch agent with new model", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + return fmt.Errorf("failed to launch agent with new model: %w", err) + } + + e.persistModelSwitchState(ctx, taskID, sessionID, session, resp, newModel) + + if err := e.agentManager.StartAgentProcess(ctx, resp.AgentExecutionID); err != nil { + e.logger.Error("failed to start agent process after model switch", + zap.String("task_id", taskID), + zap.String("agent_execution_id", resp.AgentExecutionID), + zap.Error(err)) + return fmt.Errorf("failed to start agent after model switch: %w", err) + } + + e.logger.Info("model switch complete, agent started", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("new_model", newModel), + zap.String("agent_execution_id", resp.AgentExecutionID)) + + return nil +} + +// buildSwitchModelRequest constructs a LaunchAgentRequest for a model switch, applying +// repository and worktree config from the existing session. +func (e *Executor) buildSwitchModelRequest(ctx context.Context, task *models.Task, session *models.TaskSession, sessionID, newModel, prompt, acpSessionID string, execConfig executorConfig) (*LaunchAgentRequest, error) { + req := &LaunchAgentRequest{ + TaskID: task.ID, + SessionID: sessionID, + TaskTitle: task.Title, + AgentProfileID: session.AgentProfileID, + TaskDescription: prompt, + ModelOverride: newModel, + ACPSessionID: acpSessionID, + ExecutorType: execConfig.ExecutorType, + Metadata: execConfig.Metadata, + } + + repositoryPath, err := e.applyRepositoryToSwitchRequest(ctx, req, session, execConfig) + if err != nil { + return nil, err + } + e.applyWorktreeToSwitchRequest(req, session, execConfig, repositoryPath) + + // Override repository URL with the running worktree path if available + if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { + if running.WorktreePath != "" { + req.RepositoryURL = running.WorktreePath + } + } + + return req, nil +} + +// applyRepositoryToSwitchRequest resolves the repository for a model switch and sets +// the URL and branch on the request. Returns the local repository path. +func (e *Executor) applyRepositoryToSwitchRequest(ctx context.Context, req *LaunchAgentRequest, session *models.TaskSession, execConfig executorConfig) (string, error) { + if session.RepositoryID == "" { + return "", nil + } + repository, repoErr := e.repo.GetRepository(ctx, session.RepositoryID) + if repoErr != nil || repository == nil { + return "", nil + } + req.RepositoryURL = repository.LocalPath + req.Branch = session.BaseBranch + if models.ExecutorType(execConfig.ExecutorType) == models.ExecutorTypeRemoteDocker { + cloneURL := repositoryCloneURL(repository) + if cloneURL == "" { + return "", ErrRemoteDockerNoRepoURL + } + req.RepositoryURL = cloneURL + } + return repository.LocalPath, nil +} + +// applyWorktreeToSwitchRequest configures worktree fields on the request when applicable. +func (e *Executor) applyWorktreeToSwitchRequest(req *LaunchAgentRequest, session *models.TaskSession, execConfig executorConfig, repositoryPath string) { + if !shouldUseWorktree(execConfig.ExecutorType) || repositoryPath == "" { + return + } + req.UseWorktree = true + req.RepositoryPath = repositoryPath + req.RepositoryID = session.RepositoryID + if session.BaseBranch != "" { + req.BaseBranch = session.BaseBranch + } else { + req.BaseBranch = defaultBaseBranch + } + if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" { + if req.Metadata == nil { + req.Metadata = make(map[string]interface{}) + } + req.Metadata["worktree_id"] = session.Worktrees[0].WorktreeID + } +} + +// persistModelSwitchState updates session and executor running records after a model switch launch. +func (e *Executor) persistModelSwitchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, resp *LaunchAgentResponse, newModel string) { + session.AgentExecutionID = resp.AgentExecutionID + session.ContainerID = resp.ContainerID + session.State = models.TaskSessionStateStarting + session.UpdatedAt = time.Now().UTC() + + if session.AgentProfileSnapshot == nil { + session.AgentProfileSnapshot = make(map[string]interface{}) + } + session.AgentProfileSnapshot["model"] = newModel + + if err := e.repo.UpdateTaskSession(ctx, session); err != nil { + e.logger.Error("failed to update session after model switch", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + + if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { + running.AgentExecutionID = resp.AgentExecutionID + running.ContainerID = resp.ContainerID + running.Status = "starting" + if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { + e.logger.Warn("failed to update executor running after model switch", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + } +} + +// RespondToPermission sends a response to a permission request for a session +func (e *Executor) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { + e.logger.Debug("responding to permission request", + zap.String("session_id", sessionID), + zap.String("pending_id", pendingID), + zap.String("option_id", optionID), + zap.Bool("cancelled", cancelled)) + + return e.agentManager.RespondToPermissionBySessionID(ctx, sessionID, pendingID, optionID, cancelled) +} diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go new file mode 100644 index 0000000000..607193aa11 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -0,0 +1,468 @@ +package executor + +import ( + "context" + "time" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +// repoInfo holds resolved repository details for agent launch. +type repoInfo struct { + RepositoryID string + RepositoryPath string + BaseBranch string + WorktreeBranchPrefix string + PullBeforeWorktree bool + Repository *models.Repository +} + +// resolvePrimaryRepoInfo fetches and resolves the primary repository info for a task. +func (e *Executor) resolvePrimaryRepoInfo(ctx context.Context, taskID string) (*repoInfo, error) { + info := &repoInfo{} + primaryTaskRepo, err := e.repo.GetPrimaryTaskRepository(ctx, taskID) + if err != nil { + e.logger.Error("failed to get primary task repository", + zap.String("task_id", taskID), + zap.Error(err)) + return nil, err + } + if primaryTaskRepo == nil { + return info, nil + } + info.RepositoryID = primaryTaskRepo.RepositoryID + info.BaseBranch = primaryTaskRepo.BaseBranch + if info.RepositoryID == "" { + return info, nil + } + repo, err := e.repo.GetRepository(ctx, info.RepositoryID) + if err != nil { + e.logger.Error("failed to get repository", + zap.String("repository_id", info.RepositoryID), + zap.Error(err)) + return nil, err + } + info.Repository = repo + info.RepositoryPath = repo.LocalPath + info.WorktreeBranchPrefix = repo.WorktreeBranchPrefix + info.PullBeforeWorktree = repo.PullBeforeWorktree + if info.BaseBranch == "" && repo.DefaultBranch != "" { + info.BaseBranch = repo.DefaultBranch + } + return info, nil +} + +// persistLaunchState updates the session and executor running records after a successful agent launch. +func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, resp *LaunchAgentResponse, startAgent bool, now time.Time) { + session.AgentExecutionID = resp.AgentExecutionID + session.ContainerID = resp.ContainerID + if startAgent { + session.State = models.TaskSessionStateStarting + } + session.ErrorMessage = "" + session.UpdatedAt = now + + if err := e.repo.UpdateTaskSession(ctx, session); err != nil { + e.logger.Error("failed to update agent session after launch", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + + resumable := true + if session.ExecutorID != "" { + if executor, err := e.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil { + resumable = executor.Resumable + } + } + running := &models.ExecutorRunning{ + ID: session.ID, + SessionID: session.ID, + TaskID: taskID, + ExecutorID: session.ExecutorID, + Status: "starting", + Resumable: resumable, + AgentExecutionID: resp.AgentExecutionID, + ContainerID: resp.ContainerID, + WorktreeID: resp.WorktreeID, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { + e.logger.Warn("failed to persist executor runtime after launch", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } +} + +// persistWorktreeAssociation creates a TaskSessionWorktree record if the response contains a worktree. +func (e *Executor) persistWorktreeAssociation(ctx context.Context, taskID, sessionID, repositoryID string, resp *LaunchAgentResponse) { + if resp.WorktreeID == "" { + return + } + sessionWorktree := &models.TaskSessionWorktree{ + SessionID: sessionID, + WorktreeID: resp.WorktreeID, + RepositoryID: repositoryID, + Position: 0, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { + e.logger.Error("failed to persist session worktree association", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("worktree_id", resp.WorktreeID), + zap.Error(err)) + } +} + +// ResumeSession restarts an existing task session using its stored worktree. +// When startAgent is false, only the executor runtime is started (agent process is not launched). +func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSession, startAgent bool) (*TaskExecution, error) { + task, unlock, err := e.validateAndLockResume(ctx, session) + if err != nil { + return nil, err + } + defer unlock() + + req, repositoryID, err := e.buildResumeRequest(ctx, task, session, startAgent) + if err != nil { + return nil, err + } + + e.logger.Debug("resuming agent session", + zap.String("task_id", session.TaskID), + zap.String("session_id", session.ID), + zap.String("agent_profile_id", session.AgentProfileID), + zap.String("executor_type", req.ExecutorType), + zap.String("resume_token", req.ACPSessionID), + zap.Bool("use_worktree", req.UseWorktree)) + + req.Env = e.applyPreferredShellEnv(ctx, req.Env) + + resp, err := e.agentManager.LaunchAgent(ctx, req) + if err != nil { + e.logger.Error("failed to relaunch agent for session", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID), + zap.Error(err)) + return nil, err + } + + e.persistResumeState(ctx, task.ID, session, resp, startAgent) + e.persistResumeWorktree(ctx, task.ID, session, repositoryID, resp) + + worktreePath := resp.WorktreePath + worktreeBranch := resp.WorktreeBranch + if worktreePath == "" && len(session.Worktrees) > 0 { + worktreePath = session.Worktrees[0].WorktreePath + worktreeBranch = session.Worktrees[0].WorktreeBranch + } + + now := time.Now().UTC() + execution := &TaskExecution{ + TaskID: task.ID, + AgentExecutionID: resp.AgentExecutionID, + AgentProfileID: session.AgentProfileID, + StartedAt: now, + SessionState: v1.TaskSessionStateStarting, + LastUpdate: now, + SessionID: session.ID, + WorktreePath: worktreePath, + WorktreeBranch: worktreeBranch, + } + + if startAgent { + e.startAgentProcessOnResume(task.ID, session, resp.AgentExecutionID) + } + + return execution, nil +} + +// validateAndLockResume validates the session is resumable, acquires the per-session lock, +// and loads the associated task. Returns the task, an unlock function, and any error. +// The caller must call unlock() when the critical section is complete. +func (e *Executor) validateAndLockResume(ctx context.Context, session *models.TaskSession) (*v1.Task, func(), error) { + if session == nil { + return nil, func() {}, ErrExecutionNotFound + } + + // Acquire per-session lock to prevent concurrent resume/launch operations. + // This is critical after backend restart when multiple resume requests may arrive + // simultaneously (e.g., frontend auto-resume hook firing on page open). + sessionLock := e.getSessionLock(session.ID) + sessionLock.Lock() + unlock := func() { sessionLock.Unlock() } + + taskModel, err := e.repo.GetTask(ctx, session.TaskID) + if err != nil { + unlock() + e.logger.Error("failed to load task for session resume", + zap.String("task_id", session.TaskID), + zap.String("session_id", session.ID), + zap.Error(err)) + return nil, func() {}, err + } + task := taskModel.ToAPI() + if task == nil { + unlock() + return nil, func() {}, ErrExecutionNotFound + } + + if session.AgentProfileID == "" { + unlock() + e.logger.Error("task session has no agent_profile_id configured", + zap.String("task_id", session.TaskID), + zap.String("session_id", session.ID)) + return nil, func() {}, ErrNoAgentProfileID + } + + if existing, ok := e.GetExecutionBySession(session.ID); ok && existing != nil { + unlock() + return nil, func() {}, ErrExecutionAlreadyRunning + } + + return task, unlock, nil +} + +// buildResumeRequest constructs the LaunchAgentRequest for a session resume, resolving executor config, +// repository details, worktree settings, and ACP resume token. +func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, session *models.TaskSession, startAgent bool) (*LaunchAgentRequest, string, error) { + req := &LaunchAgentRequest{ + TaskID: task.ID, + SessionID: session.ID, + TaskTitle: task.Title, + AgentProfileID: session.AgentProfileID, + TaskDescription: task.Description, + Priority: task.Priority, + } + + metadata := map[string]interface{}{} + if session.Metadata != nil { + for key, value := range session.Metadata { + metadata[key] = value + } + } + if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" { + metadata["worktree_id"] = session.Worktrees[0].WorktreeID + } + + executorWasEmpty := session.ExecutorID == "" + execConfig := e.resolveExecutorConfig(ctx, session.ExecutorID, task.WorkspaceID, metadata) + session.ExecutorID = execConfig.ExecutorID + metadata = execConfig.Metadata + req.ExecutorType = execConfig.ExecutorType + + if executorWasEmpty && session.ExecutorID != "" { + session.UpdatedAt = time.Now().UTC() + if err := e.repo.UpdateTaskSession(ctx, session); err != nil { + e.logger.Warn("failed to persist executor assignment for session", + zap.String("session_id", session.ID), + zap.String("executor_id", session.ExecutorID), + zap.Error(err)) + // Continue anyway - this is not fatal + } + } + if len(metadata) > 0 { + req.Metadata = metadata + } + + repositoryID, err := e.applyResumeRepoConfig(ctx, task, session, req) + if err != nil { + return nil, "", err + } + + if running, runErr := e.repo.GetExecutorRunningBySessionID(ctx, session.ID); runErr == nil && running != nil { + if running.ResumeToken != "" && startAgent { + req.ACPSessionID = running.ResumeToken + // Clear TaskDescription so the agent doesn't receive an automatic prompt on resume. + // The session context is restored via ACP session/load; sending a prompt here would + // cause the agent to start working immediately instead of waiting for user input. + req.TaskDescription = "" + e.logger.Info("found resume token for session resumption", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID)) + } + } + + return req, repositoryID, nil +} + +// applyResumeRepoConfig resolves repository details and applies them to req. +// Returns the resolved repositoryID. +func (e *Executor) applyResumeRepoConfig(ctx context.Context, task *v1.Task, session *models.TaskSession, req *LaunchAgentRequest) (string, error) { + repositoryID := session.RepositoryID + if repositoryID == "" && len(task.Repositories) > 0 { + repositoryID = task.Repositories[0].RepositoryID + } + + baseBranch := session.BaseBranch + if baseBranch == "" && len(task.Repositories) > 0 && task.Repositories[0].BaseBranch != "" { + baseBranch = task.Repositories[0].BaseBranch + } + if baseBranch != "" { + req.Branch = baseBranch + } + + if repositoryID == "" { + return "", nil + } + + repository, err := e.repo.GetRepository(ctx, repositoryID) + if err != nil { + e.logger.Error("failed to load repository for task session resume", + zap.String("task_id", task.ID), + zap.String("repository_id", repositoryID), + zap.Error(err)) + return "", err + } + + repositoryPath := repository.LocalPath + if repositoryPath != "" { + req.RepositoryURL = repositoryPath + } + + if models.ExecutorType(req.ExecutorType) == models.ExecutorTypeRemoteDocker { + cloneURL := repositoryCloneURL(repository) + if cloneURL == "" { + return "", ErrRemoteDockerNoRepoURL + } + req.RepositoryURL = cloneURL + } + + if shouldUseWorktree(req.ExecutorType) && repositoryPath != "" { + req.UseWorktree = true + req.RepositoryPath = repositoryPath + req.RepositoryID = repositoryID + if baseBranch != "" { + req.BaseBranch = baseBranch + } else { + req.BaseBranch = defaultBaseBranch + } + req.WorktreeBranchPrefix = repository.WorktreeBranchPrefix + req.PullBeforeWorktree = repository.PullBeforeWorktree + } + + return repositoryID, nil +} + +// persistResumeState updates session and executor running records after a successful resume launch. +func (e *Executor) persistResumeState(ctx context.Context, taskID string, session *models.TaskSession, resp *LaunchAgentResponse, startAgent bool) { + session.AgentExecutionID = resp.AgentExecutionID + session.ContainerID = resp.ContainerID + session.ErrorMessage = "" + if startAgent { + session.State = models.TaskSessionStateStarting + session.CompletedAt = nil + } + + if err := e.repo.UpdateTaskSession(ctx, session); err != nil { + e.logger.Error("failed to update task session for resume", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + } + + resumable := true + if session.ExecutorID != "" { + if executor, err := e.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil { + resumable = executor.Resumable + } + } + running := &models.ExecutorRunning{ + ID: session.ID, + SessionID: session.ID, + TaskID: taskID, + ExecutorID: session.ExecutorID, + Status: "starting", + Resumable: resumable, + AgentExecutionID: resp.AgentExecutionID, + ContainerID: resp.ContainerID, + WorktreeID: resp.WorktreeID, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { + e.logger.Warn("failed to persist executor runtime after resume", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + } +} + +// persistResumeWorktree creates a worktree association if a new worktree was allocated during resume. +func (e *Executor) persistResumeWorktree(ctx context.Context, taskID string, session *models.TaskSession, repositoryID string, resp *LaunchAgentResponse) { + if resp.WorktreeID == "" { + return + } + for _, wt := range session.Worktrees { + if wt.WorktreeID == resp.WorktreeID { + return + } + } + sessionWorktree := &models.TaskSessionWorktree{ + SessionID: session.ID, + WorktreeID: resp.WorktreeID, + RepositoryID: repositoryID, + Position: 0, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { + e.logger.Error("failed to persist session worktree association on resume", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.String("worktree_id", resp.WorktreeID), + zap.Error(err)) + } +} + +// startAgentProcessOnResume starts the agent process asynchronously after a session resume. +func (e *Executor) startAgentProcessOnResume(taskID string, session *models.TaskSession, agentExecutionID string) { + go func() { + startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + if err := e.agentManager.StartAgentProcess(startCtx, agentExecutionID); err != nil { + e.logger.Error("failed to start agent process on resume", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.String("agent_execution_id", agentExecutionID), + zap.Error(err)) + if updateErr := e.repo.UpdateTaskSessionState(context.Background(), session.ID, models.TaskSessionStateFailed, err.Error()); updateErr != nil { + e.logger.Warn("failed to mark session as failed after start error on resume", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(updateErr)) + } + if updateErr := e.updateTaskState(context.Background(), taskID, v1.TaskStateFailed); updateErr != nil { + e.logger.Warn("failed to mark task as failed after start error on resume", + zap.String("task_id", taskID), + zap.Error(updateErr)) + } + return + } + + // Agent resumed successfully - sync task state with session state. + if session.State == models.TaskSessionStateWaitingForInput { + if updateErr := e.updateTaskState(context.Background(), taskID, v1.TaskStateReview); updateErr != nil { + e.logger.Warn("failed to update task state to REVIEW after resume", + zap.String("task_id", taskID), + zap.Error(updateErr)) + } else { + e.logger.Debug("task state synced to REVIEW after resume (session waiting for input)", + zap.String("task_id", taskID), + zap.String("session_id", session.ID)) + } + } else { + e.logger.Debug("agent resumed successfully, task state unchanged", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.String("session_state", string(session.State))) + } + }() +} diff --git a/apps/backend/internal/orchestrator/executor/executor_state.go b/apps/backend/internal/orchestrator/executor/executor_state.go new file mode 100644 index 0000000000..dc1e0bc702 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_state.go @@ -0,0 +1,153 @@ +package executor + +import ( + "context" + "strings" + "time" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +// GetExecutionBySession returns the execution state for a specific session +func (e *Executor) GetExecutionBySession(sessionID string) (*TaskExecution, bool) { + ctx := context.Background() + const startupGracePeriod = 30 * time.Second + + // Load from database + session, err := e.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, false + } + if session.AgentExecutionID == "" { + return nil, false + } + + // Verify the agent is actually running + if !e.agentManager.IsAgentRunningForSession(ctx, sessionID) { + if (session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning) && + time.Since(session.UpdatedAt) < startupGracePeriod { + return FromTaskSession(session), true + } + return nil, false + } + + return FromTaskSession(session), true +} + +// ListExecutions returns all active executions +func (e *Executor) ListExecutions() []*TaskExecution { + ctx := context.Background() + sessions, err := e.repo.ListActiveTaskSessions(ctx) + if err != nil { + return nil + } + + result := make([]*TaskExecution, 0, len(sessions)) + for _, session := range sessions { + result = append(result, FromTaskSession(session)) + } + return result +} + +// ActiveCount returns the number of active executions +func (e *Executor) ActiveCount() int { + ctx := context.Background() + sessions, err := e.repo.ListActiveTaskSessions(ctx) + if err != nil { + return 0 + } + return len(sessions) +} + +// CanExecute returns true if there's capacity for another execution. +// Currently always returns true as there is no concurrent execution limit. +func (e *Executor) CanExecute() bool { + return true +} + +// MarkCompletedBySession marks an execution as completed by session ID +func (e *Executor) MarkCompletedBySession(ctx context.Context, sessionID string, state v1.TaskSessionState) { + e.logger.Info("execution completed", + zap.String("session_id", sessionID), + zap.String("state", string(state))) + + // Update database + dbState := models.TaskSessionState(state) + if err := e.repo.UpdateTaskSessionState(ctx, sessionID, dbState, ""); err != nil { + e.logger.Error("failed to update agent session status in database", + zap.String("session_id", sessionID), + zap.Error(err)) + } +} + +func (e *Executor) defaultExecutorID(ctx context.Context, workspaceID string) string { + if workspaceID == "" { + return "" + } + workspace, err := e.repo.GetWorkspace(ctx, workspaceID) + if err != nil || workspace == nil || workspace.DefaultExecutorID == nil { + return "" + } + return strings.TrimSpace(*workspace.DefaultExecutorID) +} + +// executorConfig holds resolved executor configuration. +type executorConfig struct { + ExecutorID string + ExecutorType string + ExecutorCfg map[string]string // The executor record's Config map (docker_host, etc.) + Metadata map[string]interface{} +} + +// resolveExecutorConfig resolves executor configuration from an executor ID. +// If executorID is empty, it falls back to the workspace default. +// Returns the resolved config with executor ID, type, and metadata. +func (e *Executor) resolveExecutorConfig(ctx context.Context, executorID, workspaceID string, existingMetadata map[string]interface{}) executorConfig { + resolved := executorID + if resolved == "" { + resolved = e.defaultExecutorID(ctx, workspaceID) + } + + metadata := existingMetadata + if metadata == nil { + metadata = make(map[string]interface{}) + } + + if resolved == "" { + return executorConfig{Metadata: metadata} + } + + metadata["executor_id"] = resolved + + executor, err := e.repo.GetExecutor(ctx, resolved) + if err != nil || executor == nil { + return executorConfig{ + ExecutorID: resolved, + Metadata: metadata, + } + } + + if policyJSON := strings.TrimSpace(executor.Config["mcp_policy"]); policyJSON != "" { + metadata["executor_mcp_policy"] = policyJSON + } + + return executorConfig{ + ExecutorID: resolved, + ExecutorType: string(executor.Type), + ExecutorCfg: executor.Config, + Metadata: metadata, + } +} + +func cloneMetadata(src map[string]interface{}) map[string]interface{} { + if len(src) == 0 { + return nil + } + out := make(map[string]interface{}, len(src)) + for key, value := range src { + out[key] = value + } + return out +} diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index 3732443491..634352a74e 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -145,7 +145,7 @@ func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Lo if err != nil { t.Fatalf("failed to create test repository: %v", err) } - repo := repository.Repository(repoImpl) + repo := repoImpl t.Cleanup(func() { if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index bfa0eab3d8..915777cec6 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -481,171 +481,184 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) { s.logger.Info("resuming executors on startup", zap.Int("count", len(runningExecutors))) for _, running := range runningExecutors { - sessionID := running.SessionID - if sessionID == "" { - continue - } - - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - s.logger.Warn("failed to load session for resume", - zap.String("session_id", sessionID), - zap.Error(err)) - continue - } - - previousState := session.State - - // Handle sessions in terminal states - clean up executor record and fix task state - switch previousState { - case models.TaskSessionStateCompleted, models.TaskSessionStateCancelled: - s.logger.Info("session in terminal state; cleaning up executor record", - zap.String("session_id", sessionID), - zap.String("task_id", session.TaskID), - zap.String("state", string(previousState))) - if err := s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil { - s.logger.Warn("failed to remove executor record for terminal session", - zap.String("session_id", sessionID), - zap.Error(err)) - } - continue - case models.TaskSessionStateFailed: - // If session failed, ensure task is in REVIEW state (not stuck IN_PROGRESS) - if session.TaskID != "" { - task, taskErr := s.taskRepo.GetTask(ctx, session.TaskID) - if taskErr == nil && task.State == v1.TaskStateInProgress { - s.logger.Info("fixing task state: session failed but task still IN_PROGRESS", - zap.String("task_id", session.TaskID), - zap.String("session_id", sessionID)) - if updateErr := s.taskRepo.UpdateTaskState(ctx, session.TaskID, v1.TaskStateReview); updateErr != nil { - s.logger.Warn("failed to update task state to REVIEW", - zap.String("task_id", session.TaskID), - zap.Error(updateErr)) - } - } - } - // Preserve ExecutorRunning for resumable failed sessions so the user - // can resume them later. Only clean up non-resumable failures. - if running.ResumeToken != "" && running.Resumable { - s.logger.Info("preserving executor record for resumable failed session", - zap.String("session_id", sessionID), - zap.String("task_id", session.TaskID)) - } else { - s.logger.Info("cleaning up executor record for non-resumable failed session", - zap.String("session_id", sessionID), - zap.String("task_id", session.TaskID)) - if err := s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil { - s.logger.Warn("failed to remove executor record for failed session", - zap.String("session_id", sessionID), - zap.Error(err)) - } - } - continue - } - - // Handle sessions that never started - skip without failure - if previousState == models.TaskSessionStateCreated { - s.logger.Info("session was never started; skipping recovery", - zap.String("session_id", sessionID)) - if err := s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil { - s.logger.Warn("failed to remove executor record for unstarted session", - zap.String("session_id", sessionID), - zap.Error(err)) - } - continue - } - - startAgent := running.ResumeToken != "" && running.Resumable - if !startAgent { - s.logger.Debug("resuming workspace without agent session", - zap.String("session_id", sessionID), - zap.String("previous_state", string(previousState)), - zap.Bool("has_resume_token", running.ResumeToken != ""), - zap.Bool("resumable", running.Resumable)) - } - - // Validate worktree exists before resuming - if err := validateSessionWorktrees(session); err != nil { - s.logger.Info("worktree not found, attempting recreation", - zap.String("session_id", sessionID), - zap.Error(err)) - - // Try to recreate the worktree - if s.tryRecreateWorktree(ctx, session) { - s.logger.Info("worktree recreated successfully, continuing with resume", - zap.String("session_id", sessionID)) - // Re-validate after recreation to ensure it worked - if err := validateSessionWorktrees(session); err != nil { - s.logger.Warn("worktree validation still failed after recreation", - zap.String("session_id", sessionID), - zap.Error(err)) - _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, "workspace recreation failed: "+err.Error()) - _ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID) - continue - } - } else { - // Recreation failed or not available - s.logger.Warn("worktree recreation failed; marking session as failed", - zap.String("session_id", sessionID)) - _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, "workspace not found and could not be restored") - _ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID) - continue - } - } - - s.logger.Debug("resuming session on startup", - zap.String("session_id", sessionID), - zap.String("task_id", running.TaskID), - zap.String("previous_state", string(previousState)), - zap.String("resume_token", running.ResumeToken), - zap.Bool("start_agent", startAgent)) - - // Resume the session - this will: - // 1. Create a new agentctl instance - // 2. Start the agent process - // 3. Initialize and load the session via ACP/Codex protocol - // - // ResumeSession will temporarily set state to STARTING during launch. - // After resume, the agent is immediately ready for input (session is loaded, - // no prompt is being processed). We set WAITING_FOR_INPUT directly because: - // - 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, startAgent); err != nil { - s.logger.Warn("failed to resume executor on startup", - zap.String("session_id", sessionID), - zap.Error(err)) - _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error()) - // Clear the stale execution ID to prevent "execution not found" errors - // when the user tries to prompt after the failed resume - _ = s.repo.ClearSessionExecutionID(ctx, sessionID) - _ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID) - continue - } - - 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)) - } - // Also update task state to REVIEW since we're waiting for user input - if err := s.taskRepo.UpdateTaskState(ctx, running.TaskID, v1.TaskStateReview); err != nil { - s.logger.Warn("failed to set task state to REVIEW after resume", - zap.String("task_id", running.TaskID), - zap.Error(err)) - } - } - - s.logger.Debug("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))) + s.resumeOneExecutorOnStartup(ctx, running) } } +// resumeOneExecutorOnStartup handles startup recovery for a single executor/session. +func (s *Service) resumeOneExecutorOnStartup(ctx context.Context, running *models.ExecutorRunning) { + sessionID := running.SessionID + if sessionID == "" { + return + } + + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to load session for resume", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + + previousState := session.State + + if skip := s.handleTerminalSessionOnStartup(ctx, session, running, previousState); skip { + return + } + + // Handle sessions that never started - skip without failure + if previousState == models.TaskSessionStateCreated { + s.logger.Info("session was never started; skipping recovery", + zap.String("session_id", sessionID)) + if err := s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil { + s.logger.Warn("failed to remove executor record for unstarted session", + zap.String("session_id", sessionID), + zap.Error(err)) + } + return + } + + startAgent := running.ResumeToken != "" && running.Resumable + if !startAgent { + s.logger.Debug("resuming workspace without agent session", + zap.String("session_id", sessionID), + zap.String("previous_state", string(previousState)), + zap.Bool("has_resume_token", running.ResumeToken != ""), + zap.Bool("resumable", running.Resumable)) + } + + // Validate worktree exists before resuming + if err := validateSessionWorktrees(session); err != nil { + if !s.handleWorktreeValidationFailure(ctx, session, sessionID, err) { + return + } + } + + s.logger.Debug("resuming session on startup", + zap.String("session_id", sessionID), + zap.String("task_id", running.TaskID), + zap.String("previous_state", string(previousState)), + zap.String("resume_token", running.ResumeToken), + zap.Bool("start_agent", startAgent)) + + // Resume the session - this will: + // 1. Create a new agentctl instance + // 2. Start the agent process + // 3. Initialize and load the session via ACP/Codex protocol + 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)) + _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error()) + _ = s.repo.ClearSessionExecutionID(ctx, sessionID) + _ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID) + return + } + + if startAgent { + 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 err := s.taskRepo.UpdateTaskState(ctx, running.TaskID, v1.TaskStateReview); err != nil { + s.logger.Warn("failed to set task state to REVIEW after resume", + zap.String("task_id", running.TaskID), + zap.Error(err)) + } + } + + s.logger.Debug("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))) +} + +// handleTerminalSessionOnStartup processes sessions in terminal states during startup. +// Returns true if the session should be skipped (no further processing needed). +func (s *Service) handleTerminalSessionOnStartup(ctx context.Context, session *models.TaskSession, running *models.ExecutorRunning, previousState models.TaskSessionState) bool { + sessionID := session.ID + switch previousState { + case models.TaskSessionStateCompleted, models.TaskSessionStateCancelled: + s.logger.Info("session in terminal state; cleaning up executor record", + zap.String("session_id", sessionID), + zap.String("task_id", session.TaskID), + zap.String("state", string(previousState))) + if err := s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil { + s.logger.Warn("failed to remove executor record for terminal session", + zap.String("session_id", sessionID), + zap.Error(err)) + } + return true + case models.TaskSessionStateFailed: + s.handleFailedSessionOnStartup(ctx, session, running) + return true + } + return false +} + +// handleFailedSessionOnStartup handles a failed session during startup recovery. +func (s *Service) handleFailedSessionOnStartup(ctx context.Context, session *models.TaskSession, running *models.ExecutorRunning) { + sessionID := session.ID + // If session failed, ensure task is in REVIEW state (not stuck IN_PROGRESS) + if session.TaskID != "" { + task, taskErr := s.taskRepo.GetTask(ctx, session.TaskID) + if taskErr == nil && task.State == v1.TaskStateInProgress { + s.logger.Info("fixing task state: session failed but task still IN_PROGRESS", + zap.String("task_id", session.TaskID), + zap.String("session_id", sessionID)) + if updateErr := s.taskRepo.UpdateTaskState(ctx, session.TaskID, v1.TaskStateReview); updateErr != nil { + s.logger.Warn("failed to update task state to REVIEW", + zap.String("task_id", session.TaskID), + zap.Error(updateErr)) + } + } + } + if running.ResumeToken != "" && running.Resumable { + s.logger.Info("preserving executor record for resumable failed session", + zap.String("session_id", sessionID), + zap.String("task_id", session.TaskID)) + } else { + s.logger.Info("cleaning up executor record for non-resumable failed session", + zap.String("session_id", sessionID), + zap.String("task_id", session.TaskID)) + if err := s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil { + s.logger.Warn("failed to remove executor record for failed session", + zap.String("session_id", sessionID), + zap.Error(err)) + } + } +} + +// handleWorktreeValidationFailure attempts to recreate a missing worktree. +// Returns true if recovery succeeded and resume should continue; false if the session should be skipped. +func (s *Service) handleWorktreeValidationFailure(ctx context.Context, session *models.TaskSession, sessionID string, validationErr error) bool { + s.logger.Info("worktree not found, attempting recreation", + zap.String("session_id", sessionID), + zap.Error(validationErr)) + + if !s.tryRecreateWorktree(ctx, session) { + s.logger.Warn("worktree recreation failed; marking session as failed", + zap.String("session_id", sessionID)) + _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, "workspace not found and could not be restored") + _ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID) + return false + } + + s.logger.Info("worktree recreated successfully, continuing with resume", + zap.String("session_id", sessionID)) + if err := validateSessionWorktrees(session); err != nil { + s.logger.Warn("worktree validation still failed after recreation", + zap.String("session_id", sessionID), + zap.Error(err)) + _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, "workspace recreation failed: "+err.Error()) + _ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID) + return false + } + return true +} + // tryRecreateWorktree attempts to recreate a missing worktree for a session. // On success, it updates the session's worktree info and creates an info message. // On failure, it marks the session as failed and creates an error message. @@ -693,22 +706,8 @@ func (s *Service) tryRecreateWorktree(ctx context.Context, session *models.TaskS zap.String("worktree_id", wt.WorktreeID), zap.Error(err)) - // Create error message in chat - if s.messageCreator != nil { - metadata := map[string]interface{}{ - "variant": "error", - } - _ = s.messageCreator.CreateSessionMessage( - ctx, - session.TaskID, - "Workspace could not be restored. The workspace directory was deleted and recreation failed: "+err.Error(), - session.ID, - string(v1.MessageTypeStatus), - "", // no turn ID during startup - metadata, - false, - ) - } + s.sendWorktreeStatusMessage(ctx, session.TaskID, session.ID, "error", + "Workspace could not be restored. The workspace directory was deleted and recreation failed: "+err.Error()) return false } @@ -721,26 +720,32 @@ func (s *Service) tryRecreateWorktree(ctx context.Context, session *models.TaskS zap.String("worktree_id", wt.WorktreeID), zap.String("new_path", result.WorktreePath)) - // Create info message in chat - if s.messageCreator != nil { - metadata := map[string]interface{}{ - "variant": "info", - } - _ = s.messageCreator.CreateSessionMessage( - ctx, - session.TaskID, - "Workspace was automatically restored after server restart", - session.ID, - string(v1.MessageTypeStatus), - "", // no turn ID during startup - metadata, - false, - ) - } + s.sendWorktreeStatusMessage(ctx, session.TaskID, session.ID, "info", + "Workspace was automatically restored after server restart") return true } +// sendWorktreeStatusMessage creates a status message in the session chat if messageCreator is available. +func (s *Service) sendWorktreeStatusMessage(ctx context.Context, taskID, sessionID, variant, content string) { + if s.messageCreator == nil { + return + } + metadata := map[string]interface{}{ + "variant": variant, + } + _ = s.messageCreator.CreateSessionMessage( + ctx, + taskID, + content, + sessionID, + string(v1.MessageTypeStatus), + "", // no turn ID during startup + metadata, + false, + ) +} + // IsRunning returns true if the service is running func (s *Service) IsRunning() bool { s.mu.RLock() diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 68f09615b7..74a9ccbd3d 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -417,13 +417,11 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return fmt.Errorf("workflow step getter not configured") } - // Get the workflow step step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) if err != nil { return fmt.Errorf("failed to get workflow step: %w", err) } - // Get the session session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { return fmt.Errorf("failed to get session: %w", err) @@ -432,73 +430,23 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return fmt.Errorf("session does not belong to task") } - // Get the task to use its description as the base prompt task, err := s.scheduler.GetTask(ctx, taskID) if err != nil { return fmt.Errorf("failed to get task: %w", err) } - // Check if session is in a step with pending approval - // If so, reject the request - user must use Approve button or send a chat message if session.ReviewStatus != nil && *session.ReviewStatus == "pending" { return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes") } - // Update session's workflow step to the new step - if session.WorkflowStepID == nil || *session.WorkflowStepID != workflowStepID { - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, workflowStepID); err != nil { - s.logger.Warn("failed to update session workflow step", - zap.String("session_id", sessionID), - zap.String("workflow_step_id", workflowStepID), - zap.Error(err)) - } - // Clear review status when moving to a new step - if session.ReviewStatus != nil && *session.ReviewStatus != "" { - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { - s.logger.Warn("failed to clear session review status", - zap.String("session_id", sessionID), - zap.Error(err)) - } - } - } + s.advanceSessionWorkflowStep(ctx, sessionID, workflowStepID, session) - // Build prompt from workflow step configuration effectivePrompt := s.buildWorkflowPrompt(task.Description, step, taskID) - // Check if session needs to be resumed first - // A session in "running" or "waiting_for_input" state can receive prompts directly - // Other states (created, starting, completed, failed, cancelled) need resume - sessionState := session.State - if sessionState != models.TaskSessionStateRunning && sessionState != models.TaskSessionStateWaitingForInput { - // Try to resume the session - s.logger.Debug("session not running, attempting resume", - zap.String("session_id", sessionID), - zap.String("session_state", string(session.State))) - - running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID) - if err != nil || running == nil || running.ResumeToken == "" || !running.Resumable { - return fmt.Errorf("session is not resumable (state: %s)", session.State) - } - - if err := validateSessionWorktrees(session); err != nil { - return err - } - - // Use context.WithoutCancel to prevent WebSocket request timeout from canceling the resume. - // Session resume can take time and shouldn't be tied to the WS request lifecycle. - resumeCtx := context.WithoutCancel(ctx) - _, err = s.executor.ResumeSession(resumeCtx, session, true) - if err != nil { - _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error()) - return fmt.Errorf("failed to resume session: %w", err) - } - - s.logger.Debug("session resumed, now sending prompt") + if err := s.ensureSessionRunning(ctx, sessionID, session); err != nil { + return err } - // Send the prompt using PromptTask (with planMode from step's on_enter events) - // Note: PromptTask internally uses context.WithoutCancel for the executor call - // No attachments for workflow-initiated prompts stepPlanMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) _, err = s.PromptTask(ctx, taskID, sessionID, effectivePrompt, "", stepPlanMode, nil) if err != nil { @@ -515,6 +463,57 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return nil } +// advanceSessionWorkflowStep updates the session's workflow step and clears review status if the step changed. +func (s *Service) advanceSessionWorkflowStep(ctx context.Context, sessionID, workflowStepID string, session *models.TaskSession) { + if session.WorkflowStepID != nil && *session.WorkflowStepID == workflowStepID { + return + } + if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, workflowStepID); err != nil { + s.logger.Warn("failed to update session workflow step", + zap.String("session_id", sessionID), + zap.String("workflow_step_id", workflowStepID), + zap.Error(err)) + } + if session.ReviewStatus != nil && *session.ReviewStatus != "" { + if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { + s.logger.Warn("failed to clear session review status", + zap.String("session_id", sessionID), + zap.Error(err)) + } + } +} + +// ensureSessionRunning resumes the session if it is not currently running or waiting for input. +func (s *Service) ensureSessionRunning(ctx context.Context, sessionID string, session *models.TaskSession) error { + sessionState := session.State + if sessionState == models.TaskSessionStateRunning || sessionState == models.TaskSessionStateWaitingForInput { + return nil + } + + s.logger.Debug("session not running, attempting resume", + zap.String("session_id", sessionID), + zap.String("session_state", string(session.State))) + + running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID) + if err != nil || running == nil || running.ResumeToken == "" || !running.Resumable { + return fmt.Errorf("session is not resumable (state: %s)", session.State) + } + + if err := validateSessionWorktrees(session); err != nil { + return err + } + + // Use context.WithoutCancel to prevent WebSocket request timeout from canceling the resume. + resumeCtx := context.WithoutCancel(ctx) + if _, err = s.executor.ResumeSession(resumeCtx, session, true); err != nil { + _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error()) + return fmt.Errorf("failed to resume session: %w", err) + } + + s.logger.Debug("session resumed, now sending prompt") + return nil +} + // GetTaskSessionStatus returns the status of a task session including whether it's resumable func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID string) (dto.TaskSessionStatusResponse, error) { s.logger.Debug("checking task session status", @@ -542,25 +541,10 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st resp.AgentProfileID = session.AgentProfileID // Extract resume token from executor runtime state. - var resumeToken string - if running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { - resumeToken = running.ResumeToken - resp.ACPSessionID = resumeToken - if running.Resumable { - resp.IsResumable = true - } - } + resumeToken := s.populateResumeInfo(ctx, sessionID, &resp) // Extract worktree info - if len(session.Worktrees) > 0 { - wt := session.Worktrees[0] - if wt.WorktreePath != "" { - resp.WorktreePath = &wt.WorktreePath - } - if wt.WorktreeBranch != "" { - resp.WorktreeBranch = &wt.WorktreeBranch - } - } + populateWorktreeInfo(session, &resp) // 2. Check if this session's agent is running if exec, ok := s.executor.GetExecutionBySession(sessionID); ok && exec != nil { @@ -578,10 +562,43 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st } // 4. Additional validations for resumption + return s.validateResumeEligibility(session, resp), nil +} + +// populateResumeInfo extracts resume token from executor runtime state and populates resp fields. +// Returns the resume token (may be empty). +func (s *Service) populateResumeInfo(ctx context.Context, sessionID string, resp *dto.TaskSessionStatusResponse) string { + running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID) + if err != nil || running == nil { + return "" + } + resp.ACPSessionID = running.ResumeToken + if running.Resumable { + resp.IsResumable = true + } + return running.ResumeToken +} + +// populateWorktreeInfo copies worktree path and branch into the response if present. +func populateWorktreeInfo(session *models.TaskSession, resp *dto.TaskSessionStatusResponse) { + if len(session.Worktrees) == 0 { + return + } + wt := session.Worktrees[0] + if wt.WorktreePath != "" { + resp.WorktreePath = &wt.WorktreePath + } + if wt.WorktreeBranch != "" { + resp.WorktreeBranch = &wt.WorktreeBranch + } +} + +// validateResumeEligibility performs final checks before marking a session as resumable. +func (s *Service) validateResumeEligibility(session *models.TaskSession, resp dto.TaskSessionStatusResponse) dto.TaskSessionStatusResponse { if session.AgentProfileID == "" { resp.Error = "session missing agent profile" resp.IsResumable = false - return resp, nil + return resp } // Check if worktree exists (if one was used) @@ -589,15 +606,14 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st if _, err := os.Stat(session.Worktrees[0].WorktreePath); err != nil { resp.Error = "worktree not found" resp.IsResumable = false - return resp, nil + return resp } } resp.IsAgentRunning = false resp.NeedsResume = true resp.ResumeReason = "agent_not_running" - - return resp, nil + return resp } // StopTask stops agent execution for a task (stops all active sessions for the task) @@ -671,44 +687,8 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom } // Check if model switching is requested - if model != "" { - // Get current model from agent profile snapshot (session already fetched above) - var currentModel string - if session.AgentProfileSnapshot != nil { - if m, ok := session.AgentProfileSnapshot["model"].(string); ok { - currentModel = m - } - } - - // Trigger switch if models differ - if currentModel != model { - s.logger.Info("switching model", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("from", currentModel), - zap.String("to", model)) - - // Start a new turn for this prompt (model switch case) - s.startTurnForSession(ctx, sessionID) - - // Use context.WithoutCancel to prevent WebSocket request timeout from canceling the operation. - switchCtx := context.WithoutCancel(ctx) - - // SwitchModel will stop agent, rebuild with new model, restart, and send prompt - switchResult, err := s.executor.SwitchModel(switchCtx, taskID, sessionID, model, effectivePrompt) - if err != nil { - return nil, fmt.Errorf("model switch failed: %w", err) - } - - // Update session state to RUNNING and publish event so frontend knows agent is active - s.setSessionRunning(ctx, taskID, sessionID) - - return &PromptResult{ - StopReason: switchResult.StopReason, - AgentMessage: switchResult.AgentMessage, - }, nil - } - // Model is the same, fall through to regular prompt + if result, switched, err := s.trySwitchModel(ctx, taskID, sessionID, model, effectivePrompt, session); switched || err != nil { + return result, err } previousSessionState := session.State @@ -739,6 +719,39 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom }, nil } +// trySwitchModel handles model switching for a prompt. Returns (result, true, nil) if a switch was +// performed, (nil, false, err) on error, or (nil, false, nil) if no switch was needed. +func (s *Service) trySwitchModel(ctx context.Context, taskID, sessionID, model, effectivePrompt string, session *models.TaskSession) (*PromptResult, bool, error) { + if model == "" { + return nil, false, nil + } + var currentModel string + if session.AgentProfileSnapshot != nil { + if m, ok := session.AgentProfileSnapshot["model"].(string); ok { + currentModel = m + } + } + if currentModel == model { + return nil, false, nil + } + s.logger.Info("switching model", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("from", currentModel), + zap.String("to", model)) + s.startTurnForSession(ctx, sessionID) + switchCtx := context.WithoutCancel(ctx) + switchResult, err := s.executor.SwitchModel(switchCtx, taskID, sessionID, model, effectivePrompt) + if err != nil { + return nil, true, fmt.Errorf("model switch failed: %w", err) + } + s.setSessionRunning(ctx, taskID, sessionID) + return &PromptResult{ + StopReason: switchResult.StopReason, + AgentMessage: switchResult.AgentMessage, + }, true, nil +} + // RespondToPermission sends a response to a permission request for a session func (s *Service) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { s.logger.Debug("responding to permission request", diff --git a/apps/backend/internal/prompts/handlers/handlers.go b/apps/backend/internal/prompts/handlers/handlers.go index e610c5af2e..f1b8448751 100644 --- a/apps/backend/internal/prompts/handlers/handlers.go +++ b/apps/backend/internal/prompts/handlers/handlers.go @@ -52,8 +52,7 @@ func (h *Handlers) httpCreatePrompt(c *gin.Context) { resp, err := h.controller.CreatePrompt(c.Request.Context(), req) if err != nil { status := http.StatusInternalServerError - switch err { - case service.ErrInvalidPrompt: + if err == service.ErrInvalidPrompt { status = http.StatusBadRequest } h.logger.Error("failed to create prompt", zap.Error(err)) diff --git a/apps/backend/internal/prompts/store/sqlite.go b/apps/backend/internal/prompts/store/sqlite.go index 623424a227..d61e769735 100644 --- a/apps/backend/internal/prompts/store/sqlite.go +++ b/apps/backend/internal/prompts/store/sqlite.go @@ -200,13 +200,20 @@ func (r *sqliteRepository) seedBuiltinPrompts() error { func (r *sqliteRepository) getBuiltinPrompts() []*models.Prompt { now := time.Now().UTC() return []*models.Prompt{ - { - ID: "builtin-code-review", - Name: "code-review", - Builtin: true, - CreatedAt: now, - UpdatedAt: now, - Content: `Please review the changed files in the current git worktree. + r.builtinCodeReviewPrompt(now), + r.builtinOpenPRPrompt(now), + r.builtinMergeBasePrompt(now), + } +} + +func (r *sqliteRepository) builtinCodeReviewPrompt(now time.Time) *models.Prompt { + return &models.Prompt{ + ID: "builtin-code-review", + Name: "code-review", + Builtin: true, + CreatedAt: now, + UpdatedAt: now, + Content: `Please review the changed files in the current git worktree. STEP 1: Determine what to review - First, check if there are any uncommitted changes (dirty working directory) @@ -262,14 +269,17 @@ Example format: - db/queries.go:34 - N+1 query pattern, fetch all related records in single query with join Now review the changes.`, - }, - { - ID: "builtin-open-pr", - Name: "open-pr", - Builtin: true, - CreatedAt: now, - UpdatedAt: now, - Content: `Please create and open a Pull Request for the current branch using the GitHub CLI (gh). + } +} + +func (r *sqliteRepository) builtinOpenPRPrompt(now time.Time) *models.Prompt { + return &models.Prompt{ + ID: "builtin-open-pr", + Name: "open-pr", + Builtin: true, + CreatedAt: now, + UpdatedAt: now, + Content: `Please create and open a Pull Request for the current branch using the GitHub CLI (gh). **PR Creation Steps:** 1. **Analyze the branch:** @@ -319,14 +329,17 @@ Now review the changes.`, - Make sure all commits are pushed to the remote - Verify the base branch is correct (usually 'main' or 'develop') - Check that CI/CD checks are configured to run`, - }, - { - ID: "builtin-merge-base", - Name: "merge-base", - Builtin: true, - CreatedAt: now, - UpdatedAt: now, - Content: `Please merge the base branch into the current branch and resolve any conflicts that arise. + } +} + +func (r *sqliteRepository) builtinMergeBasePrompt(now time.Time) *models.Prompt { + return &models.Prompt{ + ID: "builtin-merge-base", + Name: "merge-base", + Builtin: true, + CreatedAt: now, + UpdatedAt: now, + Content: `Please merge the base branch into the current branch and resolve any conflicts that arise. **Merge Process:** @@ -384,6 +397,5 @@ Now review the changes.`, - Never blindly accept one side without understanding both changes - Ensure code quality and functionality are maintained - If tests fail after merge, fix the issues before completing the merge`, - }, } } diff --git a/apps/backend/internal/task/handlers/environment_handlers.go b/apps/backend/internal/task/handlers/environment_handlers.go index c65c6960c4..35bcc4cdcd 100644 --- a/apps/backend/internal/task/handlers/environment_handlers.go +++ b/apps/backend/internal/task/handlers/environment_handlers.go @@ -252,25 +252,10 @@ func (h *EnvironmentHandlers) wsUpdateEnvironment(ctx context.Context, msg *ws.M return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsDeleteEnvironmentRequest struct { - ID string `json:"id"` -} - func (h *EnvironmentHandlers) wsDeleteEnvironment(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsDeleteEnvironmentRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - resp, err := h.controller.DeleteEnvironment(ctx, dto.DeleteEnvironmentRequest{ID: req.ID}) - if err != nil { - h.logger.Error("failed to delete environment", zap.Error(err)) - if errors.Is(err, controller.ErrActiveTaskSessions) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "environment is used by an active agent session", nil) - } - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete environment", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) + return wsHandleIDRequestActiveCheck(ctx, msg, h.logger, + "failed to delete environment", "environment is used by an active agent session", + func(ctx context.Context, id string) (any, error) { + return h.controller.DeleteEnvironment(ctx, dto.DeleteEnvironmentRequest{ID: id}) + }) } diff --git a/apps/backend/internal/task/handlers/executor_handlers.go b/apps/backend/internal/task/handlers/executor_handlers.go index 3c13ac1df3..aa8d3ea1b3 100644 --- a/apps/backend/internal/task/handlers/executor_handlers.go +++ b/apps/backend/internal/task/handlers/executor_handlers.go @@ -289,25 +289,10 @@ func (h *ExecutorHandlers) wsUpdateExecutor(ctx context.Context, msg *ws.Message return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsDeleteExecutorRequest struct { - ID string `json:"id"` -} - func (h *ExecutorHandlers) wsDeleteExecutor(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsDeleteExecutorRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - resp, err := h.controller.DeleteExecutor(ctx, dto.DeleteExecutorRequest{ID: req.ID}) - if err != nil { - h.logger.Error("failed to delete executor", zap.Error(err)) - if errors.Is(err, controller.ErrActiveTaskSessions) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "executor is used by an active agent session", nil) - } - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete executor", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) + return wsHandleIDRequestActiveCheck(ctx, msg, h.logger, + "failed to delete executor", "executor is used by an active agent session", + func(ctx context.Context, id string) (any, error) { + return h.controller.DeleteExecutor(ctx, dto.DeleteExecutorRequest{ID: id}) + }) } diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index e27c21de98..75e9ce1075 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -135,65 +135,30 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } req.Content = strings.TrimSpace(req.Content) - if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - // Content can be empty if there are attachments (image-only messages) - if req.Content == "" && len(req.Attachments) == 0 { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content or attachments are required", nil) + + if errMsg := validateAddMessageRequest(req); errMsg != "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, errMsg, nil) } - // Check if session is currently processing a prompt (RUNNING state) - // This prevents duplicate/concurrent prompts that can cause race conditions - sessionResp, err := h.taskController.GetTaskSession(ctx, dto.GetTaskSessionRequest{TaskSessionID: req.TaskSessionID}) - if err != nil { - h.logger.Error("failed to get task session", zap.String("session_id", req.TaskSessionID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task session", nil) + // Check session state — may block the message or flag it as a create-start + sessionResp, wsErr := h.checkSessionStateForMessage(ctx, msg, req.TaskSessionID) + if wsErr != nil { + return wsErr, nil } - if sessionResp.Session.State == models.TaskSessionStateRunning { - h.logger.Warn("rejected message submission while agent is busy", - zap.String("session_id", req.TaskSessionID), - zap.String("session_state", string(sessionResp.Session.State))) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Agent is currently processing. Please wait for the current operation to complete.", nil) - } - if sessionResp.Session.State == models.TaskSessionStateFailed || - sessionResp.Session.State == models.TaskSessionStateCancelled { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, - "Session has ended. Please create a new session to continue.", nil) - } - - // Handle CREATED sessions: save the message, then start the agent with it as the prompt isCreatedSession := sessionResp.Session.State == models.TaskSessionStateCreated - // Get the current task state to determine if we need to transition - task, err := h.taskController.GetTask(ctx, dto.GetTaskRequest{ID: req.TaskID}) - if err != nil { + // Transition task from REVIEW → IN_PROGRESS if needed + if err := h.ensureTaskInProgress(ctx, req.TaskID); err != nil { h.logger.Error("failed to get task", zap.String("task_id", req.TaskID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task", nil) } - // If task is in REVIEW state, transition back to IN_PROGRESS (also moves to matching column) - if task.State == v1.TaskStateReview { - if _, err := h.taskController.UpdateTaskState(ctx, dto.UpdateTaskStateRequest{ID: req.TaskID, State: v1.TaskStateInProgress}); err != nil { - h.logger.Error("failed to transition task from REVIEW to IN_PROGRESS", - zap.String("task_id", req.TaskID), - zap.Error(err)) - } else { - h.logger.Info("task transitioned from REVIEW to IN_PROGRESS", - zap.String("task_id", req.TaskID)) - } - } - // Build metadata with attachments, plan mode, review comments, and context files meta := orchestrator.NewUserMessageMeta(). WithPlanMode(req.PlanMode). WithReviewComments(req.HasReviewComments). WithAttachments(req.Attachments). WithContextFiles(req.ContextFiles) - metadata := meta.ToMap() message, err := h.messageController.CreateMessage(ctx, dto.CreateMessageRequest{ TaskSessionID: req.TaskSessionID, @@ -201,7 +166,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w Content: req.Content, AuthorType: "user", AuthorID: req.AuthorID, - Metadata: metadata, + Metadata: meta.ToMap(), }) if err != nil { h.logger.Error("failed to create message", zap.Error(err)) @@ -216,118 +181,209 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // Auto-forward message as prompt to running agent if orchestrator is available. // This runs async so the WS request can respond immediately. // Use context.WithoutCancel so the prompt continues even if the WebSocket client disconnects. - // The user's message is already saved, and agent responses are broadcast via notifications. if h.orchestrator != nil { - // Process on_turn_start events synchronously BEFORE sending the prompt. - // This transitions the task to the right step (e.g., Review → In Progress) - // before the agent receives the message. - if !isCreatedSession { - if err := h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID); err != nil { - h.logger.Warn("failed to process on_turn_start", - zap.String("task_id", req.TaskID), - zap.String("session_id", req.TaskSessionID), - zap.Error(err)) - } - } - taskID := req.TaskID - sessionID := req.TaskSessionID - content := req.Content - model := req.Model - planMode := req.PlanMode - attachments := req.Attachments - startCreated := isCreatedSession - agentProfileID := sessionResp.Session.AgentProfileID - go func() { - promptCtx := context.WithoutCancel(ctx) - - // For CREATED sessions, start the agent with this message as the initial prompt - if startCreated { - if err := h.orchestrator.StartCreatedSession(promptCtx, taskID, sessionID, agentProfileID, content); err != nil { - h.logger.Warn("failed to start created session from message", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(err)) - - errorMsg := "Failed to start agent" - if _, createErr := h.messageController.CreateMessage(promptCtx, dto.CreateMessageRequest{ - TaskSessionID: sessionID, - TaskID: taskID, - Content: errorMsg, - AuthorType: "agent", - Type: string(v1.MessageTypeError), - Metadata: map[string]interface{}{ - "error": err.Error(), - }, - }); createErr != nil { - h.logger.Error("failed to create error message", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(createErr)) - } - } - return - } - - _, err := h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content, model, planMode, attachments) - if err != nil { - if errors.Is(err, executor.ErrExecutionNotFound) { - if resumeErr := h.orchestrator.ResumeTaskSession(promptCtx, taskID, sessionID); resumeErr != nil { - h.logger.Warn("failed to resume task session for prompt", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(resumeErr)) - } else { - // Wait for the agent to become ready after resume. - // ResumeTaskSession starts the agent asynchronously, so we poll - // the session state until it transitions to a promptable state. - if waitErr := h.waitForSessionReady(promptCtx, sessionID); waitErr != nil { - h.logger.Warn("session did not become ready after resume", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(waitErr)) - err = waitErr - } else { - _, err = h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content, model, planMode, attachments) - } - } - } - // If prompt still failed after retries, create an error message for the user - if err != nil { - h.logger.Warn("failed to forward message as prompt to agent", - zap.String("task_id", taskID), - zap.Error(err)) - - // Create an error message so the user sees feedback - errorMsg := "Failed to send message to agent" - if strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "timeout") { - errorMsg = "Request timed out. The agent may be processing a complex task. Please try again." - } else if errors.Is(err, executor.ErrExecutionNotFound) { - errorMsg = "Agent is not running. Please restart the session." - } - - if _, createErr := h.messageController.CreateMessage(promptCtx, dto.CreateMessageRequest{ - TaskSessionID: sessionID, - TaskID: taskID, - Content: errorMsg, - AuthorType: "agent", - Type: string(v1.MessageTypeError), - Metadata: map[string]interface{}{ - "error": err.Error(), - }, - }); createErr != nil { - h.logger.Error("failed to create error message for prompt failure", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(createErr)) - } - } - } - }() + h.dispatchPromptAsync(ctx, req, sessionResp.Session.AgentProfileID, isCreatedSession) } return response, nil } +// validateAddMessageRequest returns a non-empty error string if the request is invalid. +func validateAddMessageRequest(req wsAddMessageRequest) string { + if req.TaskSessionID == "" { + return "session_id is required" + } + if req.TaskID == "" { + return "task_id is required" + } + // Content can be empty if there are attachments (image-only messages) + if req.Content == "" && len(req.Attachments) == 0 { + return "content or attachments are required" + } + return "" +} + +// checkSessionStateForMessage loads the session and returns an error WS message if the +// session is in a state that blocks new messages (running, failed, cancelled). +func (h *MessageHandlers) checkSessionStateForMessage(ctx context.Context, msg *ws.Message, sessionID string) (*dto.GetTaskSessionResponse, *ws.Message) { + sessionResp, err := h.taskController.GetTaskSession(ctx, dto.GetTaskSessionRequest{TaskSessionID: sessionID}) + if err != nil { + h.logger.Error("failed to get task session", zap.String("session_id", sessionID), zap.Error(err)) + wsErr, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task session", nil) + return nil, wsErr + } + switch sessionResp.Session.State { + case models.TaskSessionStateRunning: + h.logger.Warn("rejected message submission while agent is busy", + zap.String("session_id", sessionID), + zap.String("session_state", string(sessionResp.Session.State))) + wsErr, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Agent is currently processing. Please wait for the current operation to complete.", nil) + return nil, wsErr + case models.TaskSessionStateFailed, models.TaskSessionStateCancelled: + wsErr, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Session has ended. Please create a new session to continue.", nil) + return nil, wsErr + } + return &sessionResp, nil +} + +// ensureTaskInProgress fetches the task and transitions it from REVIEW → IN_PROGRESS if needed. +// Returns an error only when the task cannot be fetched. +func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID string) error { + task, err := h.taskController.GetTask(ctx, dto.GetTaskRequest{ID: taskID}) + if err != nil { + return err + } + if task.State != v1.TaskStateReview { + return nil + } + if _, err := h.taskController.UpdateTaskState(ctx, dto.UpdateTaskStateRequest{ID: taskID, State: v1.TaskStateInProgress}); err != nil { + h.logger.Error("failed to transition task from REVIEW to IN_PROGRESS", + zap.String("task_id", taskID), + zap.Error(err)) + } else { + h.logger.Info("task transitioned from REVIEW to IN_PROGRESS", + zap.String("task_id", taskID)) + } + return nil +} + +// dispatchPromptAsync runs on_turn_start handling and then forwards the message to the +// agent as a prompt in a background goroutine. +func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMessageRequest, agentProfileID string, isCreatedSession bool) { + // Process on_turn_start events synchronously BEFORE sending the prompt. + // This transitions the task to the right step (e.g., Review → In Progress) + // before the agent receives the message. + if !isCreatedSession { + if err := h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID); err != nil { + h.logger.Warn("failed to process on_turn_start", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.TaskSessionID), + zap.Error(err)) + } + } + taskID := req.TaskID + sessionID := req.TaskSessionID + content := req.Content + model := req.Model + planMode := req.PlanMode + attachments := req.Attachments + go func() { + promptCtx := context.WithoutCancel(ctx) + h.forwardMessageAsPrompt(promptCtx, taskID, sessionID, agentProfileID, content, model, planMode, attachments, isCreatedSession) + }() +} + +// forwardMessageAsPrompt sends a user message to the agent as a prompt. +// For CREATED sessions, it starts the agent; otherwise it prompts the running agent, +// with automatic resume handling if the agent is not found. +func (h *MessageHandlers) forwardMessageAsPrompt( + ctx context.Context, + taskID, sessionID, agentProfileID, content, model string, + planMode bool, + attachments []v1.MessageAttachment, + startCreated bool, +) { + // For CREATED sessions, start the agent with this message as the initial prompt + if startCreated { + if err := h.orchestrator.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, content); err != nil { + h.logger.Warn("failed to start created session from message", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + + errorMsg := "Failed to start agent" + if _, createErr := h.messageController.CreateMessage(ctx, dto.CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + Content: errorMsg, + AuthorType: "agent", + Type: string(v1.MessageTypeError), + Metadata: map[string]interface{}{ + "error": err.Error(), + }, + }); createErr != nil { + h.logger.Error("failed to create error message", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(createErr)) + } + } + return + } + + _, err := h.orchestrator.PromptTask(ctx, taskID, sessionID, content, model, planMode, attachments) + if err != nil { + err = h.handlePromptWithResume(ctx, taskID, sessionID, content, model, planMode, attachments, err) + } + if err != nil { + h.createPromptErrorMessage(ctx, taskID, sessionID, err) + } +} + +// handlePromptWithResume attempts to resume a session and retry a prompt when the +// initial prompt fails with ErrExecutionNotFound. +func (h *MessageHandlers) handlePromptWithResume( + ctx context.Context, + taskID, sessionID, content, model string, + planMode bool, + attachments []v1.MessageAttachment, + origErr error, +) error { + if !errors.Is(origErr, executor.ErrExecutionNotFound) { + return origErr + } + if resumeErr := h.orchestrator.ResumeTaskSession(ctx, taskID, sessionID); resumeErr != nil { + h.logger.Warn("failed to resume task session for prompt", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(resumeErr)) + return origErr + } + // Wait for the agent to become ready after resume. + // ResumeTaskSession starts the agent asynchronously, so we poll + // the session state until it transitions to a promptable state. + if waitErr := h.waitForSessionReady(ctx, sessionID); waitErr != nil { + h.logger.Warn("session did not become ready after resume", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(waitErr)) + return waitErr + } + _, err := h.orchestrator.PromptTask(ctx, taskID, sessionID, content, model, planMode, attachments) + return err +} + +// createPromptErrorMessage creates an agent error message visible to the user when +// forwarding a prompt to the agent fails. +func (h *MessageHandlers) createPromptErrorMessage(ctx context.Context, taskID, sessionID string, promptErr error) { + h.logger.Warn("failed to forward message as prompt to agent", + zap.String("task_id", taskID), + zap.Error(promptErr)) + + errorMsg := "Failed to send message to agent" + if strings.Contains(promptErr.Error(), "context deadline exceeded") || strings.Contains(promptErr.Error(), "timeout") { + errorMsg = "Request timed out. The agent may be processing a complex task. Please try again." + } else if errors.Is(promptErr, executor.ErrExecutionNotFound) { + errorMsg = "Agent is not running. Please restart the session." + } + + if _, createErr := h.messageController.CreateMessage(ctx, dto.CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + Content: errorMsg, + AuthorType: "agent", + Type: string(v1.MessageTypeError), + Metadata: map[string]interface{}{ + "error": promptErr.Error(), + }, + }); createErr != nil { + h.logger.Error("failed to create error message for prompt failure", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(createErr)) + } +} + // waitForSessionReady polls the session state after a resume operation until the agent // is ready to accept prompts. It returns nil when the session reaches WAITING_FOR_INPUT // state, or an error if the session transitions to FAILED or the timeout is exceeded. diff --git a/apps/backend/internal/task/handlers/process_handlers.go b/apps/backend/internal/task/handlers/process_handlers.go index 69d5900f85..a48d38da1b 100644 --- a/apps/backend/internal/task/handlers/process_handlers.go +++ b/apps/backend/internal/task/handlers/process_handlers.go @@ -22,6 +22,10 @@ import ( "github.com/kandev/kandev/internal/task/service" ) + +// queryValueTrue is the string "true" used in query parameter comparisons. +const queryValueTrue = "true" + type ProcessHandlers struct { service *service.Service lifecycleMgr *lifecycle.Manager @@ -58,6 +62,22 @@ func RegisterProcessRoutes( processes.GET("/:processId", handlers.httpGetProcess) } +// resolveExecutorID returns the executor ID for a session. If the session has no +// executor ID set, it falls back to the workspace default, then to the local executor. +func (h *ProcessHandlers) resolveExecutorID(ctx context.Context, session *models.TaskSession) string { + if session.ExecutorID != "" { + return session.ExecutorID + } + if task, err := h.service.GetTask(ctx, session.TaskID); err == nil { + if workspace, err := h.service.GetWorkspace(ctx, task.WorkspaceID); err == nil { + if workspace.DefaultExecutorID != nil { + return *workspace.DefaultExecutorID + } + } + } + return models.ExecutorIDLocal +} + type httpStartProcessRequest struct { Kind string `json:"kind"` ScriptName string `json:"script_name,omitempty"` @@ -92,20 +112,7 @@ func (h *ProcessHandlers) httpStartProcess(c *gin.Context) { 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.ExecutorIDLocal - } - } + executorID := h.resolveExecutorID(c.Request.Context(), session) executor, err := h.service.GetExecutor(c.Request.Context(), executorID) if err != nil { h.logger.Warn("start process executor not found", @@ -117,7 +124,137 @@ func (h *ProcessHandlers) httpStartProcess(c *gin.Context) { return } - repoID := body.RepositoryID + repoID, repo, ok := h.resolveProcessRepository(c, session, body.RepositoryID, sessionID) + if !ok { + return + } + + target, ok := h.resolveScriptTarget(c, session, body, repoID, repo, sessionID) + if !ok { + return + } + + command, portEnv, ok := h.prepareCommandWithPorts(c, sessionID, target.command) + if !ok { + return + } + + if !h.ensureAgentctlReady(c, session, sessionID) { + return + } + + switch executor.Type { + case models.ExecutorTypeLocal, models.ExecutorTypeWorktree, models.ExecutorTypeLocalDocker: + h.startLocalProcess(c, sessionID, repoID, target.kind, target.scriptName, command, target.workingDir, portEnv) + 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"}) + } +} + +// resolvedScript holds the resolved script command and metadata for a process start request. +type resolvedScript struct { + command string + kind string + scriptName string + workingDir string +} + +// resolveScriptTarget resolves the script command and working directory for a process start request. +// Returns the resolved script and true on success; on failure writes the HTTP error response and returns false. +func (h *ProcessHandlers) resolveScriptTarget( + c *gin.Context, + session *models.TaskSession, + body httpStartProcessRequest, + repoID string, + repo *models.Repository, + sessionID string, +) (*resolvedScript, bool) { + 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 nil, false + } + 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), + ) + return &resolvedScript{command: command, kind: kind, scriptName: scriptName, workingDir: workingDir}, true +} + +// prepareCommandWithPorts transforms a command by allocating port placeholders. +// Returns the transformed command, the port environment map, and true on success. +// On failure it writes the HTTP error response itself and returns false. +func (h *ProcessHandlers) prepareCommandWithPorts(c *gin.Context, sessionID, command string) (string, map[string]string, bool) { + transformed, portEnv, err := portutil.TransformCommand(command) + if err != nil { + h.logger.Error("failed to transform command with port placeholders", + zap.String("session_id", sessionID), + zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "port allocation failed"}) + return "", nil, false + } + if len(portEnv) > 0 { + h.logger.Info("allocated ports for dev process", + zap.String("session_id", sessionID), + zap.Any("ports", portEnv)) + } + return transformed, portEnv, true +} + +// ensureAgentctlReady ensures the workspace execution exists and agentctl is responsive. +// Returns true when ready; on failure it writes the HTTP error response itself and returns false. +func (h *ProcessHandlers) ensureAgentctlReady(c *gin.Context, session *models.TaskSession, sessionID string) bool { + 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 false + } + 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 false + } + return true +} + +// resolveProcessRepository resolves the repository ID and fetches the repository for a +// start-process request. Returns the repo ID, repo model, and false (writing the HTTP +// error itself) when the repository cannot be determined. +func (h *ProcessHandlers) resolveProcessRepository( + c *gin.Context, + session *models.TaskSession, + requestedRepoID, sessionID string, +) (string, *models.Repository, bool) { + repoID := requestedRepoID if repoID == "" { repoID = session.RepositoryID } @@ -134,7 +271,7 @@ func (h *ProcessHandlers) httpStartProcess(c *gin.Context) { zap.String("task_id", session.TaskID), ) c.JSON(http.StatusBadRequest, gin.H{"error": "session has no repository"}) - return + return "", nil, false } repo, err := h.service.GetRepository(c.Request.Context(), repoID) if err != nil { @@ -144,125 +281,71 @@ func (h *ProcessHandlers) httpStartProcess(c *gin.Context) { zap.Error(err), ) handleNotFound(c, h.logger, err, "repository not found") - return + return "", nil, false } + return repoID, repo, true +} - command, kind, scriptName, err := resolveScriptCommand(c.Request.Context(), h.service, repo, body.Kind, body.ScriptName) +// getRunningDevProcess returns an already-running dev process for the session, or nil. +func (h *ProcessHandlers) getRunningDevProcess(ctx context.Context, sessionID string) *agentctlclient.ProcessInfo { + existing, err := h.lifecycleMgr.ListProcesses(ctx, sessionID) 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), - ) - - // Transform command to replace port placeholders - transformedCommand, portEnv, err := portutil.TransformCommand(command) - if err != nil { - h.logger.Error("failed to transform command with port placeholders", + h.logger.Warn("failed to list processes for dev check", zap.String("session_id", sessionID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "port allocation failed"}) - return + return nil } - command = transformedCommand + for i := range existing { + proc := &existing[i] + if proc.Kind != streams.ProcessKindDev { + continue + } + if proc.Status == agentctltypes.ProcessStatusRunning || proc.Status == agentctltypes.ProcessStatusStarting { + return proc + } + } + return nil +} - if len(portEnv) > 0 { - h.logger.Info("allocated ports for dev process", +// startLocalProcess handles starting a process on a local/worktree/docker executor. +// It deduplicates running dev processes and starts the new process via the lifecycle manager. +func (h *ProcessHandlers) startLocalProcess( + c *gin.Context, + sessionID, repoID, kind, scriptName, command, workingDir string, + portEnv map[string]string, +) { + if streams.ProcessKind(kind) == streams.ProcessKindDev { + if proc := h.getRunningDevProcess(c.Request.Context(), sessionID); proc != nil { + c.JSON(http.StatusOK, gin.H{"process": proc}) + return + } + } + proc, err := h.lifecycleMgr.StartProcess(c.Request.Context(), lifecycle.StartProcessRequest{ + SessionID: sessionID, + Kind: kind, + ScriptName: scriptName, + Command: command, + WorkingDir: workingDir, + Env: portEnv, + }) + if err != nil { + h.logger.Error("failed to start process", zap.String("session_id", sessionID), - zap.Any("ports", portEnv)) - } - - 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)) + 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 } - 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.ExecutorTypeLocal, models.ExecutorTypeWorktree, 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, - Env: portEnv, - }) - 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"}) - } + 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}) } func (h *ProcessHandlers) httpStopProcessByID(c *gin.Context) { @@ -283,19 +366,7 @@ func (h *ProcessHandlers) httpStopProcessByID(c *gin.Context) { 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.ExecutorIDLocal - } - } + executorID := h.resolveExecutorID(c.Request.Context(), session) executor, err := h.service.GetExecutor(c.Request.Context(), executorID) if err != nil { handleNotFound(c, h.logger, err, "executor not found") @@ -338,20 +409,7 @@ func (h *ProcessHandlers) httpListProcesses(c *gin.Context) { 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.ExecutorIDLocal - } - } + executorID := h.resolveExecutorID(c.Request.Context(), session) executor, err := h.service.GetExecutor(c.Request.Context(), executorID) if err != nil { handleNotFound(c, h.logger, err, "executor not found") @@ -399,26 +457,13 @@ func (h *ProcessHandlers) httpGetProcess(c *gin.Context) { 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.ExecutorIDLocal - } - } + executorID := h.resolveExecutorID(c.Request.Context(), session) 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" + includeOutput := c.Query("include_output") == queryValueTrue switch executor.Type { case models.ExecutorTypeLocal, models.ExecutorTypeWorktree, models.ExecutorTypeLocalDocker: proc, err := h.lifecycleMgr.GetProcess(c.Request.Context(), processID, includeOutput) diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 9824ea24b5..28818b9aa3 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -67,7 +67,7 @@ func (h *RepositoryHandlers) registerWS(dispatcher *ws.Dispatcher) { // HTTP handlers func (h *RepositoryHandlers) httpListRepositories(c *gin.Context) { - includeScripts := c.Query("include_scripts") == "true" + includeScripts := c.Query("include_scripts") == queryValueTrue resp, err := h.controller.ListRepositories(c.Request.Context(), dto.ListRepositoriesRequest{ WorkspaceID: c.Param("id"), IncludeScripts: includeScripts, diff --git a/apps/backend/internal/task/handlers/task_git_handlers.go b/apps/backend/internal/task/handlers/task_git_handlers.go new file mode 100644 index 0000000000..fab7279450 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_git_handlers.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "context" + "time" + + "github.com/kandev/kandev/internal/task/models" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// Git Snapshot and Commit Handlers + +type wsGetGitSnapshotsRequest struct { + SessionID string `json:"session_id"` + Limit int `json:"limit,omitempty"` +} + +func (h *TaskHandlers) wsGetGitSnapshots(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetGitSnapshotsRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + snapshots, err := h.controller.GetGitSnapshots(ctx, req.SessionID, req.Limit) + if err != nil { + h.logger.Error("failed to get git snapshots", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get git snapshots", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "session_id": req.SessionID, + "snapshots": snapshots, + }) +} + +func (h *TaskHandlers) wsGetSessionData( + ctx context.Context, msg *ws.Message, + logErrMsg, clientErrMsg, resultKey string, + fn func(ctx context.Context, sessionID string) (any, error), +) (*ws.Message, error) { + var req struct { + SessionID string `json:"session_id"` + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + result, err := fn(ctx, req.SessionID) + if err != nil { + h.logger.Error(logErrMsg, zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, clientErrMsg, nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "session_id": req.SessionID, + resultKey: result, + }) +} + +func (h *TaskHandlers) wsGetSessionCommits(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return h.wsGetSessionData(ctx, msg, + "failed to get session commits", "Failed to get session commits", "commits", + func(ctx context.Context, sessionID string) (any, error) { + return h.controller.GetSessionCommits(ctx, sessionID) + }) +} + +type wsGetCumulativeDiffRequest struct { + SessionID string `json:"session_id"` +} + +func (h *TaskHandlers) wsGetCumulativeDiff(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetCumulativeDiffRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + diff, err := h.controller.GetCumulativeDiff(ctx, req.SessionID) + if err != nil { + h.logger.Error("failed to get cumulative diff", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get cumulative diff", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "cumulative_diff": diff, + }) +} + +// Session File Review Handlers + +func (h *TaskHandlers) wsGetSessionFileReviews(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return h.wsGetSessionData(ctx, msg, + "failed to get session file reviews", "Failed to get session file reviews", "reviews", + func(ctx context.Context, sessionID string) (any, error) { + return h.repo.GetSessionFileReviews(ctx, sessionID) + }) +} + +type wsUpdateSessionFileReviewRequest struct { + SessionID string `json:"session_id"` + FilePath string `json:"file_path"` + Reviewed bool `json:"reviewed"` + DiffHash string `json:"diff_hash"` +} + +func (h *TaskHandlers) wsUpdateSessionFileReview(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateSessionFileReviewRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + if req.FilePath == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "file_path is required", nil) + } + + review := &models.SessionFileReview{ + SessionID: req.SessionID, + FilePath: req.FilePath, + Reviewed: req.Reviewed, + DiffHash: req.DiffHash, + } + if req.Reviewed { + now := time.Now().UTC() + review.ReviewedAt = &now + } + + if err := h.repo.UpsertSessionFileReview(ctx, review); err != nil { + h.logger.Error("failed to update session file review", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update session file review", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "review": review, + }) +} + +type wsResetSessionFileReviewsRequest struct { + SessionID string `json:"session_id"` +} + +func (h *TaskHandlers) wsResetSessionFileReviews(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsResetSessionFileReviewsRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + if err := h.repo.DeleteSessionFileReviews(ctx, req.SessionID); err != nil { + h.logger.Error("failed to reset session file reviews", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to reset session file reviews", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + }) +} diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index c1f2a12af8..075260efe3 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -2,23 +2,14 @@ package handlers import ( "context" - "encoding/json" - "errors" - "net/http" - "strconv" - "time" "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/controller" - "github.com/kandev/kandev/internal/task/dto" - "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" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -106,984 +97,3 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionTaskPlanUpdate, h.wsUpdateTaskPlan) dispatcher.RegisterFunc(ws.ActionTaskPlanDelete, h.wsDeleteTaskPlan) } - -// HTTP handlers - -func (h *TaskHandlers) httpListTasks(c *gin.Context) { - resp, err := h.controller.ListTasks(c.Request.Context(), dto.ListTasksRequest{WorkflowID: c.Param("id")}) - if err != nil { - handleNotFound(c, h.logger, err, "tasks not found") - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpListTasksByWorkspace(c *gin.Context) { - page := 1 - pageSize := 50 - - if p := c.Query("page"); p != "" { - if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { - page = parsed - } - } - if ps := c.Query("page_size"); ps != "" { - if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 { - pageSize = parsed - } - } - - query := c.Query("query") - includeArchived := c.Query("include_archived") == "true" - - resp, err := h.controller.ListTasksByWorkspace(c.Request.Context(), dto.ListTasksByWorkspaceRequest{ - WorkspaceID: c.Param("id"), - Query: query, - Page: page, - PageSize: pageSize, - IncludeArchived: includeArchived, - }) - if err != nil { - handleNotFound(c, h.logger, err, "tasks not found") - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpGetTask(c *gin.Context) { - resp, err := h.controller.GetTask(c.Request.Context(), dto.GetTaskRequest{ID: c.Param("id")}) - if err != nil { - handleNotFound(c, h.logger, err, "task not found") - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) { - resp, err := h.controller.ListTaskSessions(c.Request.Context(), dto.ListTaskSessionsRequest{TaskID: c.Param("id")}) - if err != nil { - handleNotFound(c, h.logger, err, "task sessions not found") - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) { - resp, err := h.controller.GetTaskSession( - c.Request.Context(), - dto.GetTaskSessionRequest{TaskSessionID: c.Param("id")}, - ) - if err != nil { - handleNotFound(c, h.logger, err, "task session not found") - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpListSessionTurns(c *gin.Context) { - sessionID := c.Param("id") - if sessionID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "session id is required"}) - return - } - - turns, err := h.repo.ListTurnsBySession(c.Request.Context(), sessionID) - if err != nil { - h.logger.Error("failed to list turns", zap.String("session_id", sessionID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list turns"}) - return - } - - // Convert to DTO - turnDTOs := make([]dto.TurnDTO, 0, len(turns)) - for _, turn := range turns { - turnDTOs = append(turnDTOs, dto.FromTurn(turn)) - } - - c.JSON(http.StatusOK, dto.ListTurnsResponse{Turns: turnDTOs, Total: len(turnDTOs)}) -} - -func (h *TaskHandlers) httpApproveSession(c *gin.Context) { - resp, err := h.controller.ApproveSession( - c.Request.Context(), - dto.ApproveSessionRequest{SessionID: c.Param("id")}, - ) - if err != nil { - h.logger.Error("failed to approve session", zap.String("session_id", c.Param("id")), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpGetWorkflowTaskCount(c *gin.Context) { - resp, err := h.controller.CountTasksByWorkflow(c.Request.Context(), c.Param("id")) - if err != nil { - h.logger.Error("failed to count tasks by workflow", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tasks"}) - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpGetStepTaskCount(c *gin.Context) { - resp, err := h.controller.CountTasksByWorkflowStep(c.Request.Context(), c.Param("id")) - if err != nil { - h.logger.Error("failed to count tasks by step", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tasks"}) - return - } - c.JSON(http.StatusOK, resp) -} - -type httpBulkMoveTasksRequest struct { - SourceWorkflowID string `json:"source_workflow_id"` - SourceStepID string `json:"source_step_id,omitempty"` - TargetWorkflowID string `json:"target_workflow_id"` - TargetStepID string `json:"target_step_id"` -} - -func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { - var body httpBulkMoveTasksRequest - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - if body.SourceWorkflowID == "" || body.TargetWorkflowID == "" || body.TargetStepID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "source_workflow_id, target_workflow_id, and target_step_id are required"}) - return - } - resp, err := h.controller.BulkMoveTasks(c.Request.Context(), dto.BulkMoveTasksRequest{ - SourceWorkflowID: body.SourceWorkflowID, - SourceStepID: body.SourceStepID, - TargetWorkflowID: body.TargetWorkflowID, - TargetStepID: body.TargetStepID, - }) - if err != nil { - h.logger.Error("failed to bulk move tasks", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk move tasks"}) - return - } - c.JSON(http.StatusOK, resp) -} - -type httpTaskRepositoryInput struct { - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - LocalPath string `json:"local_path"` - Name string `json:"name"` - DefaultBranch string `json:"default_branch"` -} - -type httpCreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Priority int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - Position int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - StartAgent bool `json:"start_agent,omitempty"` - PrepareSession bool `json:"prepare_session,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - PlanMode bool `json:"plan_mode,omitempty"` -} - -type createTaskResponse struct { - dto.TaskDTO - TaskSessionID string `json:"session_id,omitempty"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` -} - -func (h *TaskHandlers) httpCreateTask(c *gin.Context) { - var body httpCreateTaskRequest - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - if body.WorkspaceID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) - return - } - if (body.StartAgent || body.PrepareSession) && body.AgentProfileID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "agent_profile_id is required to start agent"}) - return - } - - // Convert repositories - var repos []dto.TaskRepositoryInput - for _, r := range body.Repositories { - if r.RepositoryID == "" && r.LocalPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id or local_path is required"}) - return - } - repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - }) - } - - reqCtx := c.Request.Context() - resp, err := h.controller.CreateTask(reqCtx, dto.CreateTaskRequest{ - WorkspaceID: body.WorkspaceID, - WorkflowID: body.WorkflowID, - WorkflowStepID: body.WorkflowStepID, - Title: body.Title, - Description: body.Description, - Priority: body.Priority, - State: body.State, - Repositories: repos, - Position: body.Position, - Metadata: body.Metadata, - }) - if err != nil { - handleNotFound(c, h.logger, err, "task not created") - return - } - - response := createTaskResponse{TaskDTO: resp} - // Use the backend-resolved workflow step ID (from the created task) instead of the request's - resolvedStepID := resp.WorkflowStepID - if body.PrepareSession && !body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil { - // Create session entry without launching the agent. - // The session stays in CREATED state until the user triggers it. - sessionID, err := h.orchestrator.PrepareTaskSession(reqCtx, resp.ID, body.AgentProfileID, body.ExecutorID, resolvedStepID) - if err != nil { - h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", resp.ID)) - } else { - response.TaskSessionID = sessionID - } - } else if body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil { - // Create session entry synchronously so we can return the session ID immediately - sessionID, err := h.orchestrator.PrepareTaskSession(reqCtx, resp.ID, body.AgentProfileID, body.ExecutorID, resolvedStepID) - if err != nil { - h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", resp.ID)) - // Continue without session - task was created successfully - } else { - response.TaskSessionID = sessionID - - // Launch agent asynchronously so the HTTP request can return immediately. - // The frontend will receive WebSocket updates when the agent actually starts. - executorID := body.ExecutorID // Capture for goroutine - stepID := resolvedStepID // Capture for goroutine - go func() { - // Use a longer timeout to accommodate setup scripts (which can take minutes for npm install, etc.) - startCtx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout) - defer cancel() - // Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode) - execution, err := h.orchestrator.StartTaskWithSession(startCtx, resp.ID, sessionID, body.AgentProfileID, executorID, body.Priority, resp.Description, stepID, body.PlanMode) - if err != nil { - h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", resp.ID), zap.String("session_id", sessionID)) - return - } - h.logger.Info("agent started for task (async)", - zap.String("task_id", resp.ID), - zap.String("session_id", execution.SessionID), - zap.String("executor_id", executorID), - zap.String("workflow_step_id", stepID), - zap.String("execution_id", execution.AgentExecutionID)) - }() - } - } - - c.JSON(http.StatusOK, response) -} - -type httpUpdateTaskRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Priority *int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - Position *int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { - var body httpUpdateTaskRequest - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - - // Convert repositories if provided - var repos []dto.TaskRepositoryInput - if body.Repositories != nil { - for _, r := range body.Repositories { - repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - }) - } - } - - resp, err := h.controller.UpdateTask(c.Request.Context(), dto.UpdateTaskRequest{ - ID: c.Param("id"), - Title: body.Title, - Description: body.Description, - Priority: body.Priority, - State: body.State, - Repositories: repos, - Position: body.Position, - Metadata: body.Metadata, - }) - if err != nil { - handleNotFound(c, h.logger, err, "task not updated") - return - } - c.JSON(http.StatusOK, resp) -} - -type httpMoveTaskRequest struct { - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Position int `json:"position"` -} - -func (h *TaskHandlers) httpMoveTask(c *gin.Context) { - var body httpMoveTaskRequest - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - if body.WorkflowID == "" || body.WorkflowStepID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id and workflow_step_id are required"}) - return - } - resp, err := h.controller.MoveTask(c.Request.Context(), dto.MoveTaskRequest{ - ID: c.Param("id"), - WorkflowID: body.WorkflowID, - WorkflowStepID: body.WorkflowStepID, - Position: body.Position, - }) - if err != nil { - handleNotFound(c, h.logger, err, "task not moved") - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpDeleteTask(c *gin.Context) { - deleteCtx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout) - 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 - } - c.JSON(http.StatusOK, resp) -} - -func (h *TaskHandlers) httpArchiveTask(c *gin.Context) { - resp, err := h.controller.ArchiveTask(c.Request.Context(), dto.ArchiveTaskRequest{ID: c.Param("id")}) - if err != nil { - handleNotFound(c, h.logger, err, "task not archived") - return - } - c.JSON(http.StatusOK, resp) -} - -// WS handlers - -type wsListTaskSessionsRequest struct { - TaskID string `json:"task_id"` -} - -func (h *TaskHandlers) wsListTaskSessions(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsListTaskSessionsRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - resp, err := h.controller.ListTaskSessions(ctx, dto.ListTaskSessionsRequest{TaskID: req.TaskID}) - if err != nil { - h.logger.Error("failed to list task sessions", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list task sessions", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsListTasksRequest struct { - WorkflowID string `json:"workflow_id"` -} - -func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsListTasksRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.WorkflowID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) - } - - resp, err := h.controller.ListTasks(ctx, dto.ListTasksRequest{WorkflowID: req.WorkflowID}) - if err != nil { - h.logger.Error("failed to list tasks", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsCreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Priority int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - Position int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - StartAgent bool `json:"start_agent,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - PlanMode bool `json:"plan_mode,omitempty"` -} - -func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsCreateTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.WorkspaceID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) - } - if req.WorkflowID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) - } - if req.Title == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) - } - if req.StartAgent && req.AgentProfileID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required to start agent", nil) - } - - // Convert repositories - var repos []dto.TaskRepositoryInput - for _, r := range req.Repositories { - if r.RepositoryID == "" && r.LocalPath == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id or local_path is required", nil) - } - repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - }) - } - - resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{ - WorkspaceID: req.WorkspaceID, - WorkflowID: req.WorkflowID, - WorkflowStepID: req.WorkflowStepID, - Title: req.Title, - Description: req.Description, - Priority: req.Priority, - State: req.State, - Repositories: repos, - Position: req.Position, - Metadata: req.Metadata, - }) - if err != nil { - h.logger.Error("failed to create task", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil) - } - - response := createTaskResponse{TaskDTO: resp} - if req.StartAgent && req.AgentProfileID != "" && h.orchestrator != nil { - // Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode) - // Use resp.WorkflowStepID (backend-resolved) instead of req.WorkflowStepID - execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description, resp.WorkflowStepID, req.PlanMode) - if err != nil { - h.logger.Error("failed to start agent for task", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start agent for task", nil) - } - h.logger.Info("wsCreateTask started agent", - zap.String("task_id", resp.ID), - zap.String("executor_id", req.ExecutorID), - zap.String("workflow_step_id", req.WorkflowStepID), - zap.String("session_id", execution.SessionID)) - response.TaskSessionID = execution.SessionID - response.AgentExecutionID = execution.AgentExecutionID - } - return ws.NewResponse(msg.ID, msg.Action, response) -} - -type wsGetTaskRequest struct { - ID string `json:"id"` -} - -func (h *TaskHandlers) wsGetTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - - resp, err := h.controller.GetTask(ctx, dto.GetTaskRequest{ID: req.ID}) - if err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task not found", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsUpdateTaskRequest struct { - ID string `json:"id"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Priority *int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - Position *int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsUpdateTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - - // Convert repositories if provided - var repos []dto.TaskRepositoryInput - if req.Repositories != nil { - for _, r := range req.Repositories { - repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - }) - } - } - - resp, err := h.controller.UpdateTask(ctx, dto.UpdateTaskRequest{ - ID: req.ID, - Title: req.Title, - Description: req.Description, - Priority: req.Priority, - State: req.State, - Repositories: repos, - Position: req.Position, - Metadata: req.Metadata, - }) - if err != nil { - h.logger.Error("failed to update task", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsDeleteTaskRequest struct { - ID string `json:"id"` -} - -func (h *TaskHandlers) wsDeleteTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsDeleteTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - - resp, err := h.controller.DeleteTask(ctx, dto.DeleteTaskRequest{ID: req.ID}) - if err != nil { - h.logger.Error("failed to delete task", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsArchiveTaskRequest struct { - ID string `json:"id"` -} - -func (h *TaskHandlers) wsArchiveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsArchiveTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - - resp, err := h.controller.ArchiveTask(ctx, dto.ArchiveTaskRequest{ID: req.ID}) - if err != nil { - h.logger.Error("failed to archive task", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to archive task", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsMoveTaskRequest struct { - ID string `json:"id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Position int `json:"position"` -} - -func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsMoveTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - if req.WorkflowID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) - } - if req.WorkflowStepID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil) - } - - resp, err := h.controller.MoveTask(ctx, dto.MoveTaskRequest{ - ID: req.ID, - WorkflowID: req.WorkflowID, - WorkflowStepID: req.WorkflowStepID, - Position: req.Position, - }) - if err != nil { - h.logger.Error("failed to move task", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to move task", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsUpdateTaskStateRequest struct { - ID string `json:"id"` - State string `json:"state"` -} - -func (h *TaskHandlers) wsUpdateTaskState(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsUpdateTaskStateRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - if req.State == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "state is required", nil) - } - - resp, err := h.controller.UpdateTaskState(ctx, dto.UpdateTaskStateRequest{ - ID: req.ID, - State: v1.TaskState(req.State), - }) - if err != nil { - h.logger.Error("failed to update task state", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task state", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -// Git Snapshot and Commit Handlers - -type wsGetGitSnapshotsRequest struct { - SessionID string `json:"session_id"` - Limit int `json:"limit,omitempty"` -} - -func (h *TaskHandlers) wsGetGitSnapshots(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetGitSnapshotsRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.SessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - - snapshots, err := h.controller.GetGitSnapshots(ctx, req.SessionID, req.Limit) - if err != nil { - h.logger.Error("failed to get git snapshots", zap.Error(err), zap.String("session_id", req.SessionID)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get git snapshots", nil) - } - - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ - "session_id": req.SessionID, - "snapshots": snapshots, - }) -} - -type wsGetSessionCommitsRequest struct { - SessionID string `json:"session_id"` -} - -func (h *TaskHandlers) wsGetSessionCommits(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetSessionCommitsRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.SessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - - commits, err := h.controller.GetSessionCommits(ctx, req.SessionID) - if err != nil { - h.logger.Error("failed to get session commits", zap.Error(err), zap.String("session_id", req.SessionID)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get session commits", nil) - } - - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ - "session_id": req.SessionID, - "commits": commits, - }) -} - -type wsGetCumulativeDiffRequest struct { - SessionID string `json:"session_id"` -} - -func (h *TaskHandlers) wsGetCumulativeDiff(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetCumulativeDiffRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.SessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - - diff, err := h.controller.GetCumulativeDiff(ctx, req.SessionID) - if err != nil { - h.logger.Error("failed to get cumulative diff", zap.Error(err), zap.String("session_id", req.SessionID)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get cumulative diff", nil) - } - - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ - "cumulative_diff": diff, - }) -} - - -// Session File Review Handlers - -type wsGetSessionFileReviewsRequest struct { - SessionID string `json:"session_id"` -} - -func (h *TaskHandlers) wsGetSessionFileReviews(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetSessionFileReviewsRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.SessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - - reviews, err := h.repo.GetSessionFileReviews(ctx, req.SessionID) - if err != nil { - h.logger.Error("failed to get session file reviews", zap.Error(err), zap.String("session_id", req.SessionID)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get session file reviews", nil) - } - - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ - "session_id": req.SessionID, - "reviews": reviews, - }) -} - -type wsUpdateSessionFileReviewRequest struct { - SessionID string `json:"session_id"` - FilePath string `json:"file_path"` - Reviewed bool `json:"reviewed"` - DiffHash string `json:"diff_hash"` -} - -func (h *TaskHandlers) wsUpdateSessionFileReview(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsUpdateSessionFileReviewRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.SessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - if req.FilePath == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "file_path is required", nil) - } - - review := &models.SessionFileReview{ - SessionID: req.SessionID, - FilePath: req.FilePath, - Reviewed: req.Reviewed, - DiffHash: req.DiffHash, - } - if req.Reviewed { - now := time.Now().UTC() - review.ReviewedAt = &now - } - - if err := h.repo.UpsertSessionFileReview(ctx, review); err != nil { - h.logger.Error("failed to update session file review", zap.Error(err), zap.String("session_id", req.SessionID)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update session file review", nil) - } - - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ - "success": true, - "review": review, - }) -} - -type wsResetSessionFileReviewsRequest struct { - SessionID string `json:"session_id"` -} - -func (h *TaskHandlers) wsResetSessionFileReviews(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsResetSessionFileReviewsRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.SessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - - if err := h.repo.DeleteSessionFileReviews(ctx, req.SessionID); err != nil { - h.logger.Error("failed to reset session file reviews", zap.Error(err), zap.String("session_id", req.SessionID)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to reset session file reviews", nil) - } - - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ - "success": true, - }) -} - -// Task Plan Handlers - -// wsCreateTaskPlan creates a new task plan -func (h *TaskHandlers) wsCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - TaskID string `json:"task_id"` - Title string `json:"title"` - Content string `json:"content"` - CreatedBy string `json:"created_by"` - } - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - - // Default to "user" for frontend requests - createdBy := req.CreatedBy - if createdBy == "" { - createdBy = "user" - } - - plan, err := h.planService.CreatePlan(ctx, service.CreatePlanRequest{ - TaskID: req.TaskID, - Title: req.Title, - Content: req.Content, - CreatedBy: createdBy, - }) - if err != nil { - if errors.Is(err, service.ErrTaskIDRequired) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task plan: "+err.Error(), nil) - } - - return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) -} - -// wsGetTaskPlan retrieves a task plan -func (h *TaskHandlers) wsGetTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - TaskID string `json:"task_id"` - } - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - - plan, err := h.planService.GetPlan(ctx, req.TaskID) - if err != nil { - if errors.Is(err, service.ErrTaskIDRequired) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task plan", nil) - } - if plan == nil { - return ws.NewResponse(msg.ID, msg.Action, nil) - } - - return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) -} - -// wsUpdateTaskPlan updates an existing task plan -func (h *TaskHandlers) wsUpdateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - TaskID string `json:"task_id"` - Title string `json:"title"` - Content string `json:"content"` - CreatedBy string `json:"created_by"` - } - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - - // Default to "user" for frontend requests - createdBy := req.CreatedBy - if createdBy == "" { - createdBy = "user" - } - - plan, err := h.planService.UpdatePlan(ctx, service.UpdatePlanRequest{ - TaskID: req.TaskID, - Title: req.Title, - Content: req.Content, - CreatedBy: createdBy, - }) - if err != nil { - if errors.Is(err, service.ErrTaskIDRequired) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - if errors.Is(err, service.ErrTaskPlanNotFound) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil) - } - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task plan: "+err.Error(), nil) - } - - return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) -} - -// wsDeleteTaskPlan deletes a task plan -func (h *TaskHandlers) wsDeleteTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - TaskID string `json:"task_id"` - } - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - - err := h.planService.DeletePlan(ctx, req.TaskID) - if err != nil { - if errors.Is(err, service.ErrTaskIDRequired) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - if errors.Is(err, service.ErrTaskPlanNotFound) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil) - } - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task plan: "+err.Error(), nil) - } - - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true}) -} \ No newline at end of file diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go new file mode 100644 index 0000000000..571f270750 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -0,0 +1,434 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/constants" + "github.com/kandev/kandev/internal/task/dto" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +func (h *TaskHandlers) httpListTasks(c *gin.Context) { + resp, err := h.controller.ListTasks(c.Request.Context(), dto.ListTasksRequest{WorkflowID: c.Param("id")}) + if err != nil { + handleNotFound(c, h.logger, err, "tasks not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpListTasksByWorkspace(c *gin.Context) { + page := 1 + pageSize := 50 + + if p := c.Query("page"); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { + page = parsed + } + } + if ps := c.Query("page_size"); ps != "" { + if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 { + pageSize = parsed + } + } + + query := c.Query("query") + includeArchived := c.Query("include_archived") == queryValueTrue + + resp, err := h.controller.ListTasksByWorkspace(c.Request.Context(), dto.ListTasksByWorkspaceRequest{ + WorkspaceID: c.Param("id"), + Query: query, + Page: page, + PageSize: pageSize, + IncludeArchived: includeArchived, + }) + if err != nil { + handleNotFound(c, h.logger, err, "tasks not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpGetTask(c *gin.Context) { + resp, err := h.controller.GetTask(c.Request.Context(), dto.GetTaskRequest{ID: c.Param("id")}) + if err != nil { + handleNotFound(c, h.logger, err, "task not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) { + resp, err := h.controller.ListTaskSessions(c.Request.Context(), dto.ListTaskSessionsRequest{TaskID: c.Param("id")}) + if err != nil { + handleNotFound(c, h.logger, err, "task sessions not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) { + resp, err := h.controller.GetTaskSession( + c.Request.Context(), + dto.GetTaskSessionRequest{TaskSessionID: c.Param("id")}, + ) + if err != nil { + handleNotFound(c, h.logger, err, "task session not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpListSessionTurns(c *gin.Context) { + sessionID := c.Param("id") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session id is required"}) + return + } + + turns, err := h.repo.ListTurnsBySession(c.Request.Context(), sessionID) + if err != nil { + h.logger.Error("failed to list turns", zap.String("session_id", sessionID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list turns"}) + return + } + + // Convert to DTO + turnDTOs := make([]dto.TurnDTO, 0, len(turns)) + for _, turn := range turns { + turnDTOs = append(turnDTOs, dto.FromTurn(turn)) + } + + c.JSON(http.StatusOK, dto.ListTurnsResponse{Turns: turnDTOs, Total: len(turnDTOs)}) +} + +func (h *TaskHandlers) httpApproveSession(c *gin.Context) { + resp, err := h.controller.ApproveSession( + c.Request.Context(), + dto.ApproveSessionRequest{SessionID: c.Param("id")}, + ) + if err != nil { + h.logger.Error("failed to approve session", zap.String("session_id", c.Param("id")), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpGetWorkflowTaskCount(c *gin.Context) { + resp, err := h.controller.CountTasksByWorkflow(c.Request.Context(), c.Param("id")) + if err != nil { + h.logger.Error("failed to count tasks by workflow", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tasks"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpGetStepTaskCount(c *gin.Context) { + resp, err := h.controller.CountTasksByWorkflowStep(c.Request.Context(), c.Param("id")) + if err != nil { + h.logger.Error("failed to count tasks by step", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tasks"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpBulkMoveTasksRequest struct { + SourceWorkflowID string `json:"source_workflow_id"` + SourceStepID string `json:"source_step_id,omitempty"` + TargetWorkflowID string `json:"target_workflow_id"` + TargetStepID string `json:"target_step_id"` +} + +func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { + var body httpBulkMoveTasksRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.SourceWorkflowID == "" || body.TargetWorkflowID == "" || body.TargetStepID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "source_workflow_id, target_workflow_id, and target_step_id are required"}) + return + } + resp, err := h.controller.BulkMoveTasks(c.Request.Context(), dto.BulkMoveTasksRequest{ + SourceWorkflowID: body.SourceWorkflowID, + SourceStepID: body.SourceStepID, + TargetWorkflowID: body.TargetWorkflowID, + TargetStepID: body.TargetStepID, + }) + if err != nil { + h.logger.Error("failed to bulk move tasks", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk move tasks"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpTaskRepositoryInput struct { + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + LocalPath string `json:"local_path"` + Name string `json:"name"` + DefaultBranch string `json:"default_branch"` +} + +type httpCreateTaskRequest struct { + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + Position int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartAgent bool `json:"start_agent,omitempty"` + PrepareSession bool `json:"prepare_session,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` +} + +type createTaskResponse struct { + dto.TaskDTO + TaskSessionID string `json:"session_id,omitempty"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` +} + +func (h *TaskHandlers) httpCreateTask(c *gin.Context) { + var body httpCreateTaskRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.WorkspaceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) + return + } + if (body.StartAgent || body.PrepareSession) && body.AgentProfileID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent_profile_id is required to start agent"}) + return + } + + repos, ok := convertCreateTaskRepositories(c, body.Repositories) + if !ok { + return + } + + resp, err := h.controller.CreateTask(c.Request.Context(), dto.CreateTaskRequest{ + WorkspaceID: body.WorkspaceID, + WorkflowID: body.WorkflowID, + WorkflowStepID: body.WorkflowStepID, + Title: body.Title, + Description: body.Description, + Priority: body.Priority, + State: body.State, + Repositories: repos, + Position: body.Position, + Metadata: body.Metadata, + }) + if err != nil { + handleNotFound(c, h.logger, err, "task not created") + return + } + + response := createTaskResponse{TaskDTO: resp} + // Use the backend-resolved workflow step ID (from the created task) instead of the request's + resolvedStepID := resp.WorkflowStepID + h.handlePostCreateTaskSession(c, &response, resp.ID, resp.Description, body, resolvedStepID) + + c.JSON(http.StatusOK, response) +} + +// convertCreateTaskRepositories converts httpTaskRepositoryInput slice to dto.TaskRepositoryInput slice. +// Returns (nil, false) and writes a 400 response if any entry is missing both repository_id and local_path. +func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryInput) ([]dto.TaskRepositoryInput, bool) { + var repos []dto.TaskRepositoryInput + for _, r := range inputs { + if r.RepositoryID == "" && r.LocalPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id or local_path is required"}) + return nil, false + } + repos = append(repos, dto.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + }) + } + return repos, true +} + +// handlePostCreateTaskSession prepares or starts an agent session after a task is created, +// depending on the PrepareSession and StartAgent flags in the request body. +func (h *TaskHandlers) handlePostCreateTaskSession( + c *gin.Context, + response *createTaskResponse, + taskID, description string, + body httpCreateTaskRequest, + resolvedStepID string, +) { + if h.orchestrator == nil || body.AgentProfileID == "" { + return + } + if body.PrepareSession && !body.StartAgent { + // Create session entry without launching the agent. + // The session stays in CREATED state until the user triggers it. + sessionID, err := h.orchestrator.PrepareTaskSession(c.Request.Context(), taskID, body.AgentProfileID, body.ExecutorID, resolvedStepID) + if err != nil { + h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", taskID)) + } else { + response.TaskSessionID = sessionID + } + } else if body.StartAgent { + h.startAgentForNewTask(c.Request.Context(), response, taskID, description, body, resolvedStepID) + } +} + +// startAgentForNewTask prepares a session and launches the agent asynchronously for a +// newly created task when start_agent is requested. It populates response.TaskSessionID +// on success. +func (h *TaskHandlers) startAgentForNewTask( + ctx context.Context, + response *createTaskResponse, + taskID, description string, + body httpCreateTaskRequest, + resolvedStepID string, +) { + // Create session entry synchronously so we can return the session ID immediately + sessionID, err := h.orchestrator.PrepareTaskSession(ctx, taskID, body.AgentProfileID, body.ExecutorID, resolvedStepID) + if err != nil { + h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", taskID)) + // Continue without session - task was created successfully + return + } + response.TaskSessionID = sessionID + + // Launch agent asynchronously so the HTTP request can return immediately. + // The frontend will receive WebSocket updates when the agent actually starts. + executorID := body.ExecutorID // Capture for goroutine + stepID := resolvedStepID // Capture for goroutine + go func() { + // Use a longer timeout to accommodate setup scripts (which can take minutes for npm install, etc.) + startCtx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout) + defer cancel() + // Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode) + execution, err := h.orchestrator.StartTaskWithSession(startCtx, taskID, sessionID, body.AgentProfileID, executorID, body.Priority, description, stepID, body.PlanMode) + if err != nil { + h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", taskID), zap.String("session_id", sessionID)) + return + } + h.logger.Info("agent started for task (async)", + zap.String("task_id", taskID), + zap.String("session_id", execution.SessionID), + zap.String("executor_id", executorID), + zap.String("workflow_step_id", stepID), + zap.String("execution_id", execution.AgentExecutionID)) + }() +} + +type httpUpdateTaskRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + Position *int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { + var body httpUpdateTaskRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + // Convert repositories if provided + var repos []dto.TaskRepositoryInput + if body.Repositories != nil { + for _, r := range body.Repositories { + repos = append(repos, dto.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + }) + } + } + + resp, err := h.controller.UpdateTask(c.Request.Context(), dto.UpdateTaskRequest{ + ID: c.Param("id"), + Title: body.Title, + Description: body.Description, + Priority: body.Priority, + State: body.State, + Repositories: repos, + Position: body.Position, + Metadata: body.Metadata, + }) + if err != nil { + handleNotFound(c, h.logger, err, "task not updated") + return + } + c.JSON(http.StatusOK, resp) +} + +type httpMoveTaskRequest struct { + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Position int `json:"position"` +} + +func (h *TaskHandlers) httpMoveTask(c *gin.Context) { + var body httpMoveTaskRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.WorkflowID == "" || body.WorkflowStepID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id and workflow_step_id are required"}) + return + } + resp, err := h.controller.MoveTask(c.Request.Context(), dto.MoveTaskRequest{ + ID: c.Param("id"), + WorkflowID: body.WorkflowID, + WorkflowStepID: body.WorkflowStepID, + Position: body.Position, + }) + if err != nil { + handleNotFound(c, h.logger, err, "task not moved") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpDeleteTask(c *gin.Context) { + deleteCtx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout) + 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 + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpArchiveTask(c *gin.Context) { + resp, err := h.controller.ArchiveTask(c.Request.Context(), dto.ArchiveTaskRequest{ID: c.Param("id")}) + if err != nil { + handleNotFound(c, h.logger, err, "task not archived") + return + } + c.JSON(http.StatusOK, resp) +} diff --git a/apps/backend/internal/task/handlers/task_plan_handlers.go b/apps/backend/internal/task/handlers/task_plan_handlers.go new file mode 100644 index 0000000000..2a74171231 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_plan_handlers.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// Task Plan Handlers + +// wsCreateTaskPlan creates a new task plan +func (h *TaskHandlers) wsCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy string `json:"created_by"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + // Default to "user" for frontend requests + createdBy := req.CreatedBy + if createdBy == "" { + createdBy = "user" + } + + plan, err := h.planService.CreatePlan(ctx, service.CreatePlanRequest{ + TaskID: req.TaskID, + Title: req.Title, + Content: req.Content, + CreatedBy: createdBy, + }) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// wsGetTaskPlan retrieves a task plan +func (h *TaskHandlers) wsGetTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + plan, err := h.planService.GetPlan(ctx, req.TaskID) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task plan", nil) + } + if plan == nil { + return ws.NewResponse(msg.ID, msg.Action, nil) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// wsUpdateTaskPlan updates an existing task plan +func (h *TaskHandlers) wsUpdateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy string `json:"created_by"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + // Default to "user" for frontend requests + createdBy := req.CreatedBy + if createdBy == "" { + createdBy = "user" + } + + plan, err := h.planService.UpdatePlan(ctx, service.UpdatePlanRequest{ + TaskID: req.TaskID, + Title: req.Title, + Content: req.Content, + CreatedBy: createdBy, + }) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if errors.Is(err, service.ErrTaskPlanNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// wsDeleteTaskPlan deletes a task plan +func (h *TaskHandlers) wsDeleteTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + err := h.planService.DeletePlan(ctx, req.TaskID) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if errors.Is(err, service.ErrTaskPlanNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true}) +} diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go new file mode 100644 index 0000000000..0d088406b1 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -0,0 +1,290 @@ +package handlers + +import ( + "context" + + "github.com/kandev/kandev/internal/task/dto" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type wsListTaskSessionsRequest struct { + TaskID string `json:"task_id"` +} + +func (h *TaskHandlers) wsListTaskSessions(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsListTaskSessionsRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + return h.doListTaskSessions(ctx, msg, req.TaskID) +} + +func (h *TaskHandlers) doListTaskSessions(ctx context.Context, msg *ws.Message, taskID string) (*ws.Message, error) { + if taskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + resp, err := h.controller.ListTaskSessions(ctx, dto.ListTaskSessionsRequest{TaskID: taskID}) + if err != nil { + h.logger.Error("failed to list task sessions", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list task sessions", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsListTasksRequest struct { + WorkflowID string `json:"workflow_id"` +} + +func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsListTasksRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) + } + + resp, err := h.controller.ListTasks(ctx, dto.ListTasksRequest{WorkflowID: req.WorkflowID}) + if err != nil { + h.logger.Error("failed to list tasks", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsCreateTaskRequest struct { + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + Position int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartAgent bool `json:"start_agent,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` +} + +func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsCreateTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) + } + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) + } + if req.Title == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) + } + if req.StartAgent && req.AgentProfileID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required to start agent", nil) + } + + // Convert repositories + var repos []dto.TaskRepositoryInput + for _, r := range req.Repositories { + if r.RepositoryID == "" && r.LocalPath == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id or local_path is required", nil) + } + repos = append(repos, dto.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + }) + } + + resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{ + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + State: req.State, + Repositories: repos, + Position: req.Position, + Metadata: req.Metadata, + }) + if err != nil { + h.logger.Error("failed to create task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil) + } + + response := createTaskResponse{TaskDTO: resp} + if req.StartAgent && req.AgentProfileID != "" && h.orchestrator != nil { + // Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode) + // Use resp.WorkflowStepID (backend-resolved) instead of req.WorkflowStepID + execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description, resp.WorkflowStepID, req.PlanMode) + if err != nil { + h.logger.Error("failed to start agent for task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start agent for task", nil) + } + h.logger.Info("wsCreateTask started agent", + zap.String("task_id", resp.ID), + zap.String("executor_id", req.ExecutorID), + zap.String("workflow_step_id", req.WorkflowStepID), + zap.String("session_id", execution.SessionID)) + response.TaskSessionID = execution.SessionID + response.AgentExecutionID = execution.AgentExecutionID + } + return ws.NewResponse(msg.ID, msg.Action, response) +} + +type wsGetTaskRequest struct { + ID string `json:"id"` +} + +func (h *TaskHandlers) wsGetTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + resp, err := h.controller.GetTask(ctx, dto.GetTaskRequest{ID: req.ID}) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsUpdateTaskRequest struct { + ID string `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + Position *int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + // Convert repositories if provided + var repos []dto.TaskRepositoryInput + if req.Repositories != nil { + for _, r := range req.Repositories { + repos = append(repos, dto.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + }) + } + } + + resp, err := h.controller.UpdateTask(ctx, dto.UpdateTaskRequest{ + ID: req.ID, + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + State: req.State, + Repositories: repos, + Position: req.Position, + Metadata: req.Metadata, + }) + if err != nil { + h.logger.Error("failed to update task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +func (h *TaskHandlers) wsDeleteTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return wsHandleIDRequest(ctx, msg, h.logger, "failed to delete task", + func(ctx context.Context, id string) (any, error) { + return h.controller.DeleteTask(ctx, dto.DeleteTaskRequest{ID: id}) + }) +} + +func (h *TaskHandlers) wsArchiveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return wsHandleIDRequest(ctx, msg, h.logger, "failed to archive task", + func(ctx context.Context, id string) (any, error) { + return h.controller.ArchiveTask(ctx, dto.ArchiveTaskRequest{ID: id}) + }) +} + +type wsMoveTaskRequest struct { + ID string `json:"id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Position int `json:"position"` +} + +func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsMoveTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) + } + if req.WorkflowStepID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil) + } + + resp, err := h.controller.MoveTask(ctx, dto.MoveTaskRequest{ + ID: req.ID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Position: req.Position, + }) + if err != nil { + h.logger.Error("failed to move task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to move task", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsUpdateTaskStateRequest struct { + ID string `json:"id"` + State string `json:"state"` +} + +func (h *TaskHandlers) wsUpdateTaskState(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateTaskStateRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + if req.State == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "state is required", nil) + } + + resp, err := h.controller.UpdateTaskState(ctx, dto.UpdateTaskStateRequest{ + ID: req.ID, + State: v1.TaskState(req.State), + }) + if err != nil { + h.logger.Error("failed to update task state", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task state", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} diff --git a/apps/backend/internal/task/handlers/workflow_handlers.go b/apps/backend/internal/task/handlers/workflow_handlers.go index 659d53a27e..1e99dc4336 100644 --- a/apps/backend/internal/task/handlers/workflow_handlers.go +++ b/apps/backend/internal/task/handlers/workflow_handlers.go @@ -274,23 +274,9 @@ func (h *WorkflowHandlers) wsUpdateWorkflow(ctx context.Context, msg *ws.Message return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsDeleteWorkflowRequest struct { - ID string `json:"id"` -} - func (h *WorkflowHandlers) wsDeleteWorkflow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsDeleteWorkflowRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - - resp, err := h.controller.DeleteWorkflow(ctx, dto.DeleteWorkflowRequest{ID: req.ID}) - if err != nil { - h.logger.Error("failed to delete workflow", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete workflow", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) + return wsHandleIDRequest(ctx, msg, h.logger, "failed to delete workflow", + func(ctx context.Context, id string) (any, error) { + return h.controller.DeleteWorkflow(ctx, dto.DeleteWorkflowRequest{ID: id}) + }) } diff --git a/apps/backend/internal/task/handlers/workspace_handlers.go b/apps/backend/internal/task/handlers/workspace_handlers.go index 0f85e23e44..c20a102d69 100644 --- a/apps/backend/internal/task/handlers/workspace_handlers.go +++ b/apps/backend/internal/task/handlers/workspace_handlers.go @@ -169,7 +169,10 @@ func (h *WorkspaceHandlers) wsCreateWorkspace(ctx context.Context, msg *ws.Messa if req.Name == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil) } + return h.doCreateWorkspace(ctx, msg, req) +} +func (h *WorkspaceHandlers) doCreateWorkspace(ctx context.Context, msg *ws.Message, req wsCreateWorkspaceRequest) (*ws.Message, error) { resp, err := h.controller.CreateWorkspace(ctx, dto.CreateWorkspaceRequest{ Name: req.Name, Description: req.Description, @@ -238,23 +241,9 @@ func (h *WorkspaceHandlers) wsUpdateWorkspace(ctx context.Context, msg *ws.Messa return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsDeleteWorkspaceRequest struct { - ID string `json:"id"` -} - func (h *WorkspaceHandlers) wsDeleteWorkspace(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsDeleteWorkspaceRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - - resp, err := h.controller.DeleteWorkspace(ctx, dto.DeleteWorkspaceRequest{ID: req.ID}) - if err != nil { - h.logger.Error("failed to delete workspace", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete workspace", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) + return wsHandleIDRequest(ctx, msg, h.logger, "failed to delete workspace", + func(ctx context.Context, id string) (any, error) { + return h.controller.DeleteWorkspace(ctx, dto.DeleteWorkspaceRequest{ID: id}) + }) } diff --git a/apps/backend/internal/task/handlers/ws_helpers.go b/apps/backend/internal/task/handlers/ws_helpers.go new file mode 100644 index 0000000000..e26837ee45 --- /dev/null +++ b/apps/backend/internal/task/handlers/ws_helpers.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "context" + "errors" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/controller" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// wsIDOnlyRequest is the common request struct for WS handlers that take a single ID field. +type wsIDOnlyRequest struct { + ID string `json:"id"` +} + +// wsHandleIDRequest handles the common WS handler pattern for operations with a single "id" field. +// fn receives the ID and returns the response payload and any error. +func wsHandleIDRequest( + ctx context.Context, + msg *ws.Message, + log *logger.Logger, + errMsg string, + fn func(ctx context.Context, id string) (any, error), +) (*ws.Message, error) { + var req wsIDOnlyRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := fn(ctx, req.ID) + if err != nil { + log.Error(errMsg, zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, errMsg, nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +// wsHandleIDRequestActiveCheck is like wsHandleIDRequest but also handles +// controller.ErrActiveTaskSessions with a specific validation error message. +func wsHandleIDRequestActiveCheck( + ctx context.Context, + msg *ws.Message, + log *logger.Logger, + errMsg, activeSessionMsg string, + fn func(ctx context.Context, id string) (any, error), +) (*ws.Message, error) { + var req wsIDOnlyRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := fn(ctx, req.ID) + if err != nil { + log.Error(errMsg, zap.Error(err)) + if errors.Is(err, controller.ErrActiveTaskSessions) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, activeSessionMsg, nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, errMsg, nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} diff --git a/apps/backend/internal/task/repository/archive_repository_test.go b/apps/backend/internal/task/repository/archive_repository_test.go new file mode 100644 index 0000000000..28c069e04f --- /dev/null +++ b/apps/backend/internal/task/repository/archive_repository_test.go @@ -0,0 +1,243 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestSQLiteRepository_ArchiveTask(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task One"}) + + // Archive the task + err := repo.ArchiveTask(ctx, "task-1") + if err != nil { + t.Fatalf("failed to archive task: %v", err) + } + + // Verify archived_at is set + task, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("failed to get task: %v", err) + } + if task.ArchivedAt == nil { + t.Fatal("expected archived_at to be set") + } + + // Archive again should fail + err = repo.ArchiveTask(ctx, "task-1") + // The repo method doesn't check for already archived, but it still succeeds (updates the timestamp) + if err != nil { + t.Fatalf("unexpected error archiving already-archived task: %v", err) + } + + // Archive non-existent task should fail + err = repo.ArchiveTask(ctx, "nonexistent") + if err == nil { + t.Error("expected error when archiving nonexistent task") + } +} + +func TestSQLiteRepository_ArchiveTask_ExcludesFromListTasks(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task One"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task Two"}) + + // Archive task-1 + _ = repo.ArchiveTask(ctx, "task-1") + + // ListTasks should exclude archived tasks + tasks, err := repo.ListTasks(ctx, "wf-1") + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if len(tasks) != 1 { + t.Errorf("expected 1 task (excluding archived), got %d", len(tasks)) + } + if tasks[0].ID != "task-2" { + t.Errorf("expected task-2, got %s", tasks[0].ID) + } + + // ListTasksByWorkflowStep should exclude archived tasks + tasksByStep, err := repo.ListTasksByWorkflowStep(ctx, "step-1") + if err != nil { + t.Fatalf("failed to list tasks by step: %v", err) + } + if len(tasksByStep) != 1 { + t.Errorf("expected 1 task by step (excluding archived), got %d", len(tasksByStep)) + } + + // CountTasksByWorkflow should exclude archived tasks + count, err := repo.CountTasksByWorkflow(ctx, "wf-1") + if err != nil { + t.Fatalf("failed to count tasks: %v", err) + } + if count != 1 { + t.Errorf("expected count 1, got %d", count) + } + + // CountTasksByWorkflowStep should exclude archived tasks + countByStep, err := repo.CountTasksByWorkflowStep(ctx, "step-1") + if err != nil { + t.Fatalf("failed to count tasks by step: %v", err) + } + if countByStep != 1 { + t.Errorf("expected count 1, got %d", countByStep) + } +} + +func TestSQLiteRepository_ListTasksByWorkspace_IncludeArchived(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Active Task"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Archived Task"}) + _ = repo.ArchiveTask(ctx, "task-2") + + // Without includeArchived: should return only active task + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10, false) + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if total != 1 { + t.Errorf("expected 1 total without archived, got %d", total) + } + if len(tasks) != 1 || tasks[0].ID != "task-1" { + t.Errorf("expected only task-1, got %v", tasks) + } + + // With includeArchived: should return both tasks + tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10, true) + if err != nil { + t.Fatalf("failed to list tasks with archived: %v", err) + } + if total != 2 { + t.Errorf("expected 2 total with archived, got %d", total) + } + if len(tasks) != 2 { + t.Errorf("expected 2 tasks, got %d", len(tasks)) + } + + // Search with includeArchived=false should filter archived + _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "Task", 1, 10, false) + if err != nil { + t.Fatalf("failed to search tasks: %v", err) + } + if total != 1 { + t.Errorf("expected 1 search result without archived, got %d", total) + } + + // Search with includeArchived=true should include archived + _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "Task", 1, 10, true) + if err != nil { + t.Fatalf("failed to search tasks with archived: %v", err) + } + if total != 2 { + t.Errorf("expected 2 search results with archived, got %d", total) + } +} + +func TestSQLiteRepository_ListTasksForAutoArchive(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create the workflow_steps table (normally created by workflow repo) + _, err := repo.DB().Exec(` + CREATE TABLE IF NOT EXISTS workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + name TEXT NOT NULL, + position INTEGER NOT NULL, + color TEXT, + prompt TEXT, + events TEXT, + allow_manual_move INTEGER DEFAULT 1, + is_start_step INTEGER DEFAULT 0, + auto_archive_after_hours INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + `) + if err != nil { + t.Fatalf("failed to create workflow_steps table: %v", err) + } + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + now := time.Now().UTC() + + // Create workflow step with auto-archive after 1 hour + _, err = repo.DB().Exec(`INSERT INTO workflow_steps (id, workflow_id, name, position, auto_archive_after_hours, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, "step-done", "wf-1", "Done", 2, 1, now, now) + if err != nil { + t.Fatalf("failed to create workflow step: %v", err) + } + + // Create workflow step without auto-archive + _, err = repo.DB().Exec(`INSERT INTO workflow_steps (id, workflow_id, name, position, auto_archive_after_hours, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, "step-todo", "wf-1", "Todo", 1, 0, now, now) + if err != nil { + t.Fatalf("failed to create workflow step: %v", err) + } + + oldTime := now.Add(-2 * time.Hour) + + // Task in auto-archive step, updated 2 hours ago — should be eligible + _ = repo.CreateTask(ctx, &models.Task{ + ID: "task-old", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Old Task", + }) + // Backdate updated_at (CreateTask sets it to now) + _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-old") + + // Task in auto-archive step, updated just now — should NOT be eligible + _ = repo.CreateTask(ctx, &models.Task{ + ID: "task-recent", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Recent Task", + }) + + // Task in non-auto-archive step — should NOT be eligible + _ = repo.CreateTask(ctx, &models.Task{ + ID: "task-todo", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-todo", Title: "Todo Task", + }) + _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-todo") + + // Already archived task — should NOT be eligible + _ = repo.CreateTask(ctx, &models.Task{ + ID: "task-archived", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Already Archived", + }) + _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-archived") + _ = repo.ArchiveTask(ctx, "task-archived") + + // List candidates + candidates, err := repo.ListTasksForAutoArchive(ctx) + if err != nil { + t.Fatalf("failed to list auto-archive candidates: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(candidates)) + } + if candidates[0].ID != "task-old" { + t.Errorf("expected task-old, got %s", candidates[0].ID) + } +} diff --git a/apps/backend/internal/task/repository/message_repository_test.go b/apps/backend/internal/task/repository/message_repository_test.go new file mode 100644 index 0000000000..6f9ac5712d --- /dev/null +++ b/apps/backend/internal/task/repository/message_repository_test.go @@ -0,0 +1,237 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +func setupSQLiteTestSession(ctx context.Context, repo *sqlite.Repository, taskID, sessionID string) string { + session := &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.TaskSessionStateStarting, + } + _ = repo.CreateTaskSession(ctx, session) + return session.ID +} + +func setupSQLiteTestTurn(ctx context.Context, repo *sqlite.Repository, sessionID, taskID, turnID string) string { + now := time.Now() + turn := &models.Turn{ + ID: turnID, + TaskSessionID: sessionID, + TaskID: taskID, + StartedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + _ = repo.CreateTurn(ctx, turn) + return turn.ID +} + +// Message CRUD tests + +func TestSQLiteRepository_MessageCRUD(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workflow first (required for foreign key constraints) + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + // Create a task first + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") + turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") + + // Create comment + comment := &models.Message{ + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: turnID, + AuthorType: models.MessageAuthorUser, + AuthorID: "user-123", + Content: "This is a test comment", + RequestsInput: false, + } + if err := repo.CreateMessage(ctx, comment); err != nil { + t.Fatalf("failed to create comment: %v", err) + } + if comment.ID == "" { + t.Error("expected comment ID to be set") + } + if comment.CreatedAt.IsZero() { + t.Error("expected CreatedAt to be set") + } + + // Get comment + retrieved, err := repo.GetMessage(ctx, comment.ID) + if err != nil { + t.Fatalf("failed to get comment: %v", err) + } + if retrieved.Content != "This is a test comment" { + t.Errorf("expected content 'This is a test comment', got %s", retrieved.Content) + } + if retrieved.AuthorType != models.MessageAuthorUser { + t.Errorf("expected author type 'user', got %s", retrieved.AuthorType) + } + + // Delete comment + if err := repo.DeleteMessage(ctx, comment.ID); err != nil { + t.Fatalf("failed to delete comment: %v", err) + } + _, err = repo.GetMessage(ctx, comment.ID) + if err == nil { + t.Error("expected comment to be deleted") + } +} + +func TestSQLiteRepository_MessageNotFound(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _, err := repo.GetMessage(ctx, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent comment") + } + + err = repo.DeleteMessage(ctx, "nonexistent") + if err == nil { + t.Error("expected error for deleting nonexistent comment") + } +} + +func TestSQLiteRepository_ListMessages(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workflow first (required for foreign key constraints) + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + // Create tasks + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + task2 := &models.Task{ID: "task-456", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task 2"} + _ = repo.CreateTask(ctx, task2) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") + session2ID := setupSQLiteTestSession(ctx, repo, task2.ID, "session-456") + turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") + turn2ID := setupSQLiteTestTurn(ctx, repo, session2ID, task2.ID, "turn-456") + + // Create multiple comments + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-1", TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, AuthorType: models.MessageAuthorUser, Content: "Comment 1"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-2", TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, AuthorType: models.MessageAuthorAgent, Content: "Comment 2"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-3", TaskSessionID: session2ID, TaskID: "task-456", TurnID: turn2ID, AuthorType: models.MessageAuthorUser, Content: "Comment 3"}) + + comments, err := repo.ListMessages(ctx, sessionID) + if err != nil { + t.Fatalf("failed to list comments: %v", err) + } + if len(comments) != 2 { + t.Errorf("expected 2 comments for task-123, got %d", len(comments)) + } +} + +func TestSQLiteRepository_ListMessagesPagination(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") + turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") + + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-1", + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: turnID, + AuthorType: models.MessageAuthorUser, + Content: "Comment 1", + CreatedAt: baseTime.Add(-2 * time.Minute), + }) + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-2", + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: turnID, + AuthorType: models.MessageAuthorUser, + Content: "Comment 2", + CreatedAt: baseTime.Add(-1 * time.Minute), + }) + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-3", + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: turnID, + AuthorType: models.MessageAuthorUser, + Content: "Comment 3", + CreatedAt: baseTime, + }) + + comments, hasMore, err := repo.ListMessagesPaginated(ctx, sessionID, models.ListMessagesOptions{ + Limit: 2, + Sort: "desc", + }) + if err != nil { + t.Fatalf("failed to list comments: %v", err) + } + if len(comments) != 2 { + t.Fatalf("expected 2 comments, got %d", len(comments)) + } + if !hasMore { + t.Error("expected hasMore to be true") + } + if comments[0].ID != "comment-3" { + t.Errorf("expected newest comment first, got %s", comments[0].ID) + } +} + +func TestSQLiteRepository_MessageWithRequestsInput(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workflow first (required for foreign key constraints) + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + // Create a task + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") + turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") + + // Create agent comment requesting input + comment := &models.Message{ + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: turnID, + AuthorType: models.MessageAuthorAgent, + AuthorID: "agent-123", + Content: "What should I do next?", + RequestsInput: true, + } + if err := repo.CreateMessage(ctx, comment); err != nil { + t.Fatalf("failed to create comment: %v", err) + } + + retrieved, _ := repo.GetMessage(ctx, comment.ID) + if !retrieved.RequestsInput { + t.Error("expected RequestsInput to be true") + } +} diff --git a/apps/backend/internal/task/repository/repository_test.go b/apps/backend/internal/task/repository/repository_test.go new file mode 100644 index 0000000000..a0e62ef79b --- /dev/null +++ b/apps/backend/internal/task/repository/repository_test.go @@ -0,0 +1,91 @@ +package repository + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/worktree" +) + +func createTestSQLiteRepo(t *testing.T) (*sqlite.Repository, func()) { + t.Helper() + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("failed to open SQLite database: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := sqlite.NewWithDB(sqlxDB) + if err != nil { + t.Fatalf("failed to create SQLite repository: %v", err) + } + if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { + t.Fatalf("failed to init worktree store: %v", err) + } + + cleanup := func() { + if err := sqlxDB.Close(); err != nil { + t.Errorf("failed to close sqlite db: %v", err) + } + if err := repo.Close(); err != nil { + t.Errorf("failed to close repo: %v", err) + } + } + + return repo, cleanup +} + +func TestNewSQLiteRepositoryWithDB(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + + if repo == nil { + t.Fatal("expected non-nil repository") + } + if repo.DB() == nil { + t.Error("expected db to be initialized") + } +} + +func TestSQLiteRepository_Close(t *testing.T) { + repo, _ := createTestSQLiteRepo(t) + err := repo.Close() + if err != nil { + t.Errorf("expected no error on close, got %v", err) + } +} + +func TestSQLiteRepository_SeedsDefaultWorkspace(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + workspaces, err := repo.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("failed to list workspaces: %v", err) + } + if len(workspaces) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(workspaces)) + } + if workspaces[0].Name != "Default Workspace" { + t.Errorf("expected Default Workspace, got %s", workspaces[0].Name) + } + + workflows, err := repo.ListWorkflows(ctx, workspaces[0].ID) + if err != nil { + t.Fatalf("failed to list workflows: %v", err) + } + if len(workflows) != 1 { + t.Fatalf("expected 1 workflow, got %d", len(workflows)) + } + if workflows[0].Name != "Development" { + t.Errorf("expected Development workflow, got %s", workflows[0].Name) + } + // Note: workflow steps are now managed by the workflow repository, not the task repository +} diff --git a/apps/backend/internal/task/repository/session_repository_test.go b/apps/backend/internal/task/repository/session_repository_test.go new file mode 100644 index 0000000000..eac491aa3f --- /dev/null +++ b/apps/backend/internal/task/repository/session_repository_test.go @@ -0,0 +1,402 @@ +package repository + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// TaskSession CRUD tests + +func TestSQLiteRepository_TaskSessionCRUD(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workflow and task first (required for foreign key constraints) + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + + // Create agent session + session := &models.TaskSession{ + TaskID: "task-123", + AgentExecutionID: "execution-abc", + ContainerID: "container-xyz", + AgentProfileID: "profile-123", + ExecutorID: "executor-1", + EnvironmentID: "env-1", + State: models.TaskSessionStateStarting, + Metadata: map[string]interface{}{"key": "value"}, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to create agent session: %v", err) + } + if session.ID == "" { + t.Error("expected session ID to be set") + } + if session.StartedAt.IsZero() { + t.Error("expected StartedAt to be set") + } + if session.UpdatedAt.IsZero() { + t.Error("expected UpdatedAt to be set") + } + + // Get agent session + retrieved, err := repo.GetTaskSession(ctx, session.ID) + if err != nil { + t.Fatalf("failed to get agent session: %v", err) + } + if retrieved.TaskID != "task-123" { + t.Errorf("expected TaskID 'task-123', got %s", retrieved.TaskID) + } + if retrieved.AgentProfileID != "profile-123" { + t.Errorf("expected AgentProfileID 'profile-123', got %s", retrieved.AgentProfileID) + } + if retrieved.State != models.TaskSessionStateStarting { + t.Errorf("expected state 'starting', got %s", retrieved.State) + } + if retrieved.Metadata["key"] != "value" { + t.Errorf("expected metadata key 'value', got %v", retrieved.Metadata["key"]) + } + + // Update agent session + session.State = models.TaskSessionStateRunning + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update agent session: %v", err) + } + retrieved, _ = repo.GetTaskSession(ctx, session.ID) + if retrieved.State != models.TaskSessionStateRunning { + t.Errorf("expected state 'running', got %s", retrieved.State) + } + + // Delete agent session + if err := repo.DeleteTaskSession(ctx, session.ID); err != nil { + t.Fatalf("failed to delete agent session: %v", err) + } + _, err = repo.GetTaskSession(ctx, session.ID) + if err == nil { + t.Error("expected agent session to be deleted") + } +} + +func TestSQLiteRepository_TaskSessionNotFound(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _, err := repo.GetTaskSession(ctx, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent agent session") + } + + err = repo.UpdateTaskSession(ctx, &models.TaskSession{ID: "nonexistent", TaskID: "task-123"}) + if err == nil { + t.Error("expected error for updating nonexistent agent session") + } + + err = repo.DeleteTaskSession(ctx, "nonexistent") + if err == nil { + t.Error("expected error for deleting nonexistent agent session") + } +} + +func TestSQLiteRepository_TaskSessionByTaskID(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workflow and task + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + + // Create multiple sessions for the same task (simulating session history) + session1 := &models.TaskSession{ + ID: "session-1", + TaskID: "task-123", + AgentProfileID: "profile-1", + State: models.TaskSessionStateCompleted, + } + _ = repo.CreateTaskSession(ctx, session1) + + session2 := &models.TaskSession{ + ID: "session-2", + TaskID: "task-123", + AgentProfileID: "profile-2", + State: models.TaskSessionStateRunning, + } + _ = repo.CreateTaskSession(ctx, session2) + + // GetTaskSessionByTaskID should return the most recent session + retrieved, err := repo.GetTaskSessionByTaskID(ctx, "task-123") + if err != nil { + t.Fatalf("failed to get agent session by task ID: %v", err) + } + if retrieved.ID != "session-2" { + t.Errorf("expected session-2 (most recent), got %s", retrieved.ID) + } + + // GetActiveTaskSessionByTaskID should return the active session + active, err := repo.GetActiveTaskSessionByTaskID(ctx, "task-123") + if err != nil { + t.Fatalf("failed to get active agent session by task ID: %v", err) + } + if active.ID != "session-2" { + t.Errorf("expected session-2 (active), got %s", active.ID) + } + if active.State != models.TaskSessionStateRunning { + t.Errorf("expected state 'running', got %s", active.State) + } + + // Test when no active session exists + session2.State = models.TaskSessionStateCompleted + _ = repo.UpdateTaskSession(ctx, session2) + + _, err = repo.GetActiveTaskSessionByTaskID(ctx, "task-123") + if err == nil { + t.Error("expected error when no active session exists") + } + + // Test for nonexistent task + _, err = repo.GetTaskSessionByTaskID(ctx, "nonexistent-task") + if err == nil { + t.Error("expected error for nonexistent task") + } +} + +func TestSQLiteRepository_ListTaskSessions(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workflow and tasks + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task1 := &models.Task{ID: "task-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1"} + _ = repo.CreateTask(ctx, task1) + task2 := &models.Task{ID: "task-2", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2"} + _ = repo.CreateTask(ctx, task2) + + // Create sessions for different tasks + _ = repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-1", TaskID: "task-1", AgentProfileID: "profile-1", State: models.TaskSessionStateCompleted}) + _ = repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-2", TaskID: "task-1", AgentProfileID: "profile-1", State: models.TaskSessionStateRunning}) + _ = repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-3", TaskID: "task-2", AgentProfileID: "profile-2", State: models.TaskSessionStateStarting}) + + // List sessions for task-1 + sessions, err := repo.ListTaskSessions(ctx, "task-1") + if err != nil { + t.Fatalf("failed to list agent sessions: %v", err) + } + if len(sessions) != 2 { + t.Errorf("expected 2 sessions for task-1, got %d", len(sessions)) + } + + // List all active sessions + activeSessions, err := repo.ListActiveTaskSessions(ctx) + if err != nil { + t.Fatalf("failed to list active agent sessions: %v", err) + } + if len(activeSessions) != 2 { + t.Errorf("expected 2 active sessions, got %d", len(activeSessions)) + } + + // Verify only active statuses are returned + for _, s := range activeSessions { + if s.State != models.TaskSessionStateStarting && s.State != models.TaskSessionStateRunning && s.State != models.TaskSessionStateWaitingForInput { + t.Errorf("expected active state, got %s", s.State) + } + } +} + +func TestSQLiteRepository_UpdateTaskSessionState(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workflow and task + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + + // Create an agent session + session := &models.TaskSession{ + ID: "session-123", + TaskID: "task-123", + AgentProfileID: "profile-1", + State: models.TaskSessionStateStarting, + } + _ = repo.CreateTaskSession(ctx, session) + + // Update to running status + err := repo.UpdateTaskSessionState(ctx, "session-123", models.TaskSessionStateRunning, "") + if err != nil { + t.Fatalf("failed to update agent session status: %v", err) + } + retrieved, _ := repo.GetTaskSession(ctx, "session-123") + if retrieved.State != models.TaskSessionStateRunning { + t.Errorf("expected state 'running', got %s", retrieved.State) + } + if retrieved.CompletedAt != nil { + t.Error("expected CompletedAt to be nil for running status") + } + + // Update to completed status (should set CompletedAt) + err = repo.UpdateTaskSessionState(ctx, "session-123", models.TaskSessionStateCompleted, "") + if err != nil { + t.Fatalf("failed to update agent session status to completed: %v", err) + } + retrieved, _ = repo.GetTaskSession(ctx, "session-123") + if retrieved.State != models.TaskSessionStateCompleted { + t.Errorf("expected state 'completed', got %s", retrieved.State) + } + if retrieved.CompletedAt == nil { + t.Error("expected CompletedAt to be set for completed status") + } + + // Test failed status with error message + session2 := &models.TaskSession{ + ID: "session-456", + TaskID: "task-123", + AgentProfileID: "profile-1", + State: models.TaskSessionStateRunning, + } + _ = repo.CreateTaskSession(ctx, session2) + + err = repo.UpdateTaskSessionState(ctx, "session-456", models.TaskSessionStateFailed, "connection timeout") + if err != nil { + t.Fatalf("failed to update agent session status to failed: %v", err) + } + retrieved, _ = repo.GetTaskSession(ctx, "session-456") + if retrieved.State != models.TaskSessionStateFailed { + t.Errorf("expected state 'failed', got %s", retrieved.State) + } + if retrieved.ErrorMessage != "connection timeout" { + t.Errorf("expected error message 'connection timeout', got %s", retrieved.ErrorMessage) + } + if retrieved.CompletedAt == nil { + t.Error("expected CompletedAt to be set for failed status") + } + + // Test stopped status + session3 := &models.TaskSession{ + ID: "session-789", + TaskID: "task-123", + AgentProfileID: "profile-1", + State: models.TaskSessionStateRunning, + } + _ = repo.CreateTaskSession(ctx, session3) + + err = repo.UpdateTaskSessionState(ctx, "session-789", models.TaskSessionStateCancelled, "") + if err != nil { + t.Fatalf("failed to update agent session status to stopped: %v", err) + } + retrieved, _ = repo.GetTaskSession(ctx, "session-789") + if retrieved.State != models.TaskSessionStateCancelled { + t.Errorf("expected state 'cancelled', got %s", retrieved.State) + } + if retrieved.CompletedAt == nil { + t.Error("expected CompletedAt to be set for stopped status") + } + + // Test nonexistent session + err = repo.UpdateTaskSessionState(ctx, "nonexistent", models.TaskSessionStateRunning, "") + if err == nil { + t.Error("expected error for updating nonexistent session status") + } +} + +func TestSQLiteRepository_CompleteRunningToolCallsForTurn(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Setup + workflow := &models.Workflow{ID: "wf-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Test Task"} + _ = repo.CreateTask(ctx, task) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-1") + turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-1") + + // Create a tool call message with status "running" + runningTool := &models.Message{ + ID: "msg-running-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Running tool", + Type: models.MessageTypeToolCall, + Metadata: map[string]interface{}{"tool_call_id": "tc-1", "status": "running"}, + } + // Create a tool call message already "complete" + completeTool := &models.Message{ + ID: "msg-complete-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Complete tool", + Type: models.MessageTypeToolCall, + Metadata: map[string]interface{}{"tool_call_id": "tc-2", "status": "complete"}, + } + // Create a regular message (no tool_call_id) with status "running" — should NOT be affected + regularMsg := &models.Message{ + ID: "msg-regular-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Regular message", + Type: models.MessageTypeMessage, + Metadata: map[string]interface{}{"status": "running"}, + } + // Create a second running tool call + runningTool2 := &models.Message{ + ID: "msg-running-2", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Content: "Running tool 2", + Type: models.MessageTypeToolCall, + Metadata: map[string]interface{}{"tool_call_id": "tc-3", "status": "running"}, + } + + for _, msg := range []*models.Message{runningTool, completeTool, regularMsg, runningTool2} { + if err := repo.CreateMessage(ctx, msg); err != nil { + t.Fatalf("failed to create message %s: %v", msg.ID, err) + } + } + + // Execute + affected, err := repo.CompleteRunningToolCallsForTurn(ctx, turnID) + if err != nil { + t.Fatalf("CompleteRunningToolCallsForTurn failed: %v", err) + } + + // Should have updated exactly 2 running tool call messages + if affected != 2 { + t.Errorf("expected 2 affected rows, got %d", affected) + } + + // Verify running tool calls are now "complete" + msg1, _ := repo.GetMessage(ctx, "msg-running-1") + if msg1.Metadata["status"] != "complete" { + t.Errorf("expected msg-running-1 status 'complete', got %v", msg1.Metadata["status"]) + } + msg2, _ := repo.GetMessage(ctx, "msg-running-2") + if msg2.Metadata["status"] != "complete" { + t.Errorf("expected msg-running-2 status 'complete', got %v", msg2.Metadata["status"]) + } + + // Verify already-complete tool call is unchanged + msg3, _ := repo.GetMessage(ctx, "msg-complete-1") + if msg3.Metadata["status"] != "complete" { + t.Errorf("expected msg-complete-1 status 'complete', got %v", msg3.Metadata["status"]) + } + + // Verify regular message (no tool_call_id) was NOT affected + msg4, _ := repo.GetMessage(ctx, "msg-regular-1") + if msg4.Metadata["status"] != "running" { + t.Errorf("expected msg-regular-1 status 'running' (unchanged), got %v", msg4.Metadata["status"]) + } + + // Running again should affect 0 rows + affected2, err := repo.CompleteRunningToolCallsForTurn(ctx, turnID) + if err != nil { + t.Fatalf("second CompleteRunningToolCallsForTurn failed: %v", err) + } + if affected2 != 0 { + t.Errorf("expected 0 affected rows on second call, got %d", affected2) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 759c5d11a2..ae59912c28 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -58,7 +58,42 @@ func (r *Repository) ensureWorkspaceIndexes() error { // initSchema creates the database tables if they don't exist func (r *Repository) initSchema() error { - schema := ` + if err := r.initCoreSchema(); err != nil { + return err + } + if err := r.initPlansSchema(); err != nil { + return err + } + if err := r.initSessionSchema(); err != nil { + return err + } + if err := r.initGitSchema(); err != nil { + return err + } + if err := r.initReviewSchema(); err != nil { + return err + } + if err := r.ensureDefaultWorkspace(); err != nil { + return err + } + if err := r.ensureDefaultExecutorsAndEnvironments(); err != nil { + return err + } + return r.ensureWorkspaceIndexes() +} + +func (r *Repository) initCoreSchema() error { + if err := r.initInfraSchema(); err != nil { + return err + } + if err := r.initTaskSchema(); err != nil { + return err + } + return r.initCoreIndexes() +} + +func (r *Repository) initInfraSchema() error { + _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -130,7 +165,12 @@ func (r *Repository) initSchema() error { created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); + `) + return err +} +func (r *Repository) initTaskSchema() error { + _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', @@ -194,7 +234,12 @@ func (r *Repository) initSchema() error { updated_at TIMESTAMP NOT NULL, FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE ); + `) + return err +} +func (r *Repository) initCoreIndexes() error { + _, err := r.db.Exec(` CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id); CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id); CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at); @@ -202,14 +247,12 @@ func (r *Repository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id); CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id); CREATE INDEX IF NOT EXISTS idx_repository_scripts_repo_id ON repository_scripts(repository_id); - ` + `) + return err +} - if _, err := r.db.Exec(schema); err != nil { - return err - } - - // Task plans table - plansSchema := ` +func (r *Repository) initPlansSchema() error { + _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_plans ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL UNIQUE, @@ -221,13 +264,19 @@ func (r *Repository) initSchema() error { FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_task_plans_task_id ON task_plans(task_id); - ` - if _, err := r.db.Exec(plansSchema); err != nil { + `) + return err +} + +func (r *Repository) initSessionSchema() error { + if err := r.initMessageTurnSchema(); err != nil { return err } + return r.initSessionWorktreeSchema() +} - // Session and message tables - sessionSchema := ` +func (r *Repository) initMessageTurnSchema() error { + _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_session_messages ( id TEXT PRIMARY KEY, task_session_id TEXT NOT NULL, @@ -264,7 +313,12 @@ func (r *Repository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_turns_session_id ON task_session_turns(task_session_id); CREATE INDEX IF NOT EXISTS idx_turns_session_started ON task_session_turns(task_session_id, started_at); CREATE INDEX IF NOT EXISTS idx_turns_task_id ON task_session_turns(task_id); + `) + return err +} +func (r *Repository) initSessionWorktreeSchema() error { + _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_sessions ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, @@ -317,14 +371,12 @@ func (r *Repository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_worktree_id ON task_session_worktrees(worktree_id); CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_repository_id ON task_session_worktrees(repository_id); CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_status ON task_session_worktrees(status); - ` + `) + return err +} - if _, err := r.db.Exec(sessionSchema); err != nil { - return err - } - - // Git tracking tables - gitSchema := ` +func (r *Repository) initGitSchema() error { + _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_session_git_snapshots ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, @@ -365,13 +417,12 @@ func (r *Repository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_session_commits_session ON task_session_commits(session_id, committed_at DESC); CREATE INDEX IF NOT EXISTS idx_session_commits_sha ON task_session_commits(commit_sha); - ` + `) + return err +} - if _, err := r.db.Exec(gitSchema); err != nil { - return err - } - - reviewSchema := ` +func (r *Repository) initReviewSchema() error { + _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS session_file_reviews ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, @@ -385,24 +436,7 @@ func (r *Repository) initSchema() error { UNIQUE(session_id, file_path) ); CREATE INDEX IF NOT EXISTS idx_session_file_reviews_session ON session_file_reviews(session_id); - ` - - if _, err := r.db.Exec(reviewSchema); err != nil { - return err - } - - if err := r.ensureDefaultWorkspace(); err != nil { - return err - } - - if err := r.ensureDefaultExecutorsAndEnvironments(); err != nil { - return err - } - - if err := r.ensureWorkspaceIndexes(); err != nil { - return err - } - - return nil + `) + return err } diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index c7a6fa50c3..690f715c39 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -23,49 +23,9 @@ func (r *Repository) ensureDefaultWorkspace() error { } if count == 0 { - var workflowCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM workflows").Scan(&workflowCount); err != nil { + if err := r.createInitialWorkspace(ctx); err != nil { return err } - var taskCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM tasks").Scan(&taskCount); err != nil { - return err - } - defaultID := uuid.New().String() - now := time.Now().UTC() - workspaceName := "Default Workspace" - workspaceDescription := "Default workspace" - if workflowCount > 0 || taskCount > 0 { - workspaceName = "Migrated Workspace" - workspaceDescription = "" - } - if _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO workspaces ( - id, - name, - description, - owner_id, - default_executor_id, - default_environment_id, - default_agent_profile_id, - created_at, - updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `), defaultID, workspaceName, workspaceDescription, "", nil, nil, nil, now, now); err != nil { - return err - } - - if workflowCount == 0 && taskCount == 0 { - workflowID := uuid.New().String() - if _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `), workflowID, defaultID, "Development", "Default development workflow", "simple", now, now); err != nil { - return err - } - // Note: Workflow steps will be created by the workflow repository after it initializes - } } var defaultWorkspaceID string @@ -92,85 +52,133 @@ func (r *Repository) ensureDefaultWorkspace() error { return nil } +// createInitialWorkspace inserts the first workspace and optionally a default workflow. +func (r *Repository) createInitialWorkspace(ctx context.Context) error { + var workflowCount int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM workflows").Scan(&workflowCount); err != nil { + return err + } + var taskCount int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM tasks").Scan(&taskCount); err != nil { + return err + } + defaultID := uuid.New().String() + now := time.Now().UTC() + workspaceName := "Default Workspace" + workspaceDescription := "Default workspace" + if workflowCount > 0 || taskCount > 0 { + workspaceName = "Migrated Workspace" + workspaceDescription = "" + } + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO workspaces ( + id, + name, + description, + owner_id, + default_executor_id, + default_environment_id, + default_agent_profile_id, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), defaultID, workspaceName, workspaceDescription, "", nil, nil, nil, now, now); err != nil { + return err + } + if workflowCount == 0 && taskCount == 0 { + workflowID := uuid.New().String() + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `), workflowID, defaultID, "Development", "Default development workflow", "simple", now, now); err != nil { + return err + } + // Note: Workflow steps will be created by the workflow repository after it initializes + } + return nil +} + // ensureDefaultExecutorsAndEnvironments creates default executors and environments if none exist func (r *Repository) ensureDefaultExecutorsAndEnvironments() error { ctx := context.Background() + if err := r.ensureDefaultExecutors(ctx); err != nil { + return err + } + return r.ensureDefaultEnvironment(ctx) +} +func (r *Repository) ensureDefaultExecutors(ctx context.Context) error { var executorCount int if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM executors").Scan(&executorCount); err != nil { return err } - if executorCount == 0 { - now := time.Now().UTC() - executors := []struct { - id string - name string - execType models.ExecutorType - status models.ExecutorStatus - isSystem bool - resumable bool - config map[string]string - }{ - { - id: models.ExecutorIDLocal, - name: "Local", - execType: models.ExecutorTypeLocal, - status: models.ExecutorStatusActive, - isSystem: true, - resumable: true, - config: map[string]string{}, - }, - { - id: models.ExecutorIDWorktree, - name: "Worktree", - execType: models.ExecutorTypeWorktree, - status: models.ExecutorStatusActive, - isSystem: true, - resumable: true, - config: map[string]string{}, - }, - { - id: models.ExecutorIDLocalDocker, - name: "Local Docker", - execType: models.ExecutorTypeLocalDocker, - status: models.ExecutorStatusActive, - isSystem: false, - resumable: true, - config: map[string]string{ - "docker_host": config.DefaultDockerHost(), - }, - }, - } - - for _, executor := range executors { - configJSON, err := json.Marshal(executor.config) - if err != nil { - return fmt.Errorf("failed to serialize executor config: %w", err) - } - if _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `), executor.id, executor.name, executor.execType, executor.status, dialect.BoolToInt(executor.isSystem), dialect.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil { - return err - } - } - } else { - // Ensure system executors have is_system flag set - for _, systemID := range []string{models.ExecutorIDLocal, models.ExecutorIDWorktree} { - if _, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE executors SET is_system = 1 WHERE id = ? - `), systemID); err != nil { - return err - } + return r.insertDefaultExecutors(ctx) + } + // Ensure system executors have is_system flag set + for _, systemID := range []string{models.ExecutorIDLocal, models.ExecutorIDWorktree} { + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE executors SET is_system = 1 WHERE id = ? + `), systemID); err != nil { + return err } } + return nil +} +func (r *Repository) insertDefaultExecutors(ctx context.Context) error { + now := time.Now().UTC() + executors := []struct { + id string + name string + execType models.ExecutorType + status models.ExecutorStatus + isSystem bool + resumable bool + config map[string]string + }{ + {id: models.ExecutorIDLocal, name: "Local", execType: models.ExecutorTypeLocal, status: models.ExecutorStatusActive, isSystem: true, resumable: true, config: map[string]string{}}, + {id: models.ExecutorIDWorktree, name: "Worktree", execType: models.ExecutorTypeWorktree, status: models.ExecutorStatusActive, isSystem: true, resumable: true, config: map[string]string{}}, + {id: models.ExecutorIDLocalDocker, name: "Local Docker", execType: models.ExecutorTypeLocalDocker, status: models.ExecutorStatusActive, isSystem: false, resumable: true, config: map[string]string{"docker_host": config.DefaultDockerHost()}}, + } + for _, executor := range executors { + configJSON, err := json.Marshal(executor.config) + if err != nil { + return fmt.Errorf("failed to serialize executor config: %w", err) + } + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), executor.id, executor.name, executor.execType, executor.status, dialect.BoolToInt(executor.isSystem), dialect.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ensureDefaultEnvironment(ctx context.Context) error { var envCount int if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM environments").Scan(&envCount); err != nil { return err } if envCount == 0 { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now) + return err + } + return r.updateDefaultEnvironment(ctx) +} + +func (r *Repository) updateDefaultEnvironment(ctx context.Context) error { + var localCount int + if err := r.db.QueryRowContext(ctx, r.db.Rebind("SELECT COUNT(1) FROM environments WHERE id = ?"), models.EnvironmentIDLocal).Scan(&localCount); err != nil { + return err + } + if localCount == 0 { now := time.Now().UTC() if _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) @@ -178,39 +186,15 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error { `), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil { return err } - } else { - var localCount int - if err := r.db.QueryRowContext(ctx, r.db.Rebind("SELECT COUNT(1) FROM environments WHERE id = ?"), models.EnvironmentIDLocal).Scan(&localCount); err != nil { - return err - } - if localCount == 0 { - now := time.Now().UTC() - if _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil { - return err - } - } - if _, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE environments - SET is_system = 1, - image_tag = '', - dockerfile = '', - build_config = '{}' - WHERE id = ? - `), models.EnvironmentIDLocal); err != nil { - return err - } - if _, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE environments - SET worktree_root = ? - WHERE id = ? AND (worktree_root IS NULL OR worktree_root = '') - `), "~/kandev", models.EnvironmentIDLocal); err != nil { - return err - } } - - return nil + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE environments SET is_system = 1, image_tag = '', dockerfile = '', build_config = '{}' WHERE id = ? + `), models.EnvironmentIDLocal); err != nil { + return err + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE environments SET worktree_root = ? WHERE id = ? AND (worktree_root IS NULL OR worktree_root = '') + `), "~/kandev", models.EnvironmentIDLocal) + return err } diff --git a/apps/backend/internal/task/repository/sqlite/git_snapshots.go b/apps/backend/internal/task/repository/sqlite/git_snapshots.go index db3522c4d1..05a71216ad 100644 --- a/apps/backend/internal/task/repository/sqlite/git_snapshots.go +++ b/apps/backend/internal/task/repository/sqlite/git_snapshots.go @@ -51,21 +51,20 @@ func (r *Repository) CreateGitSnapshot(ctx context.Context, snapshot *models.Git return err } -// GetLatestGitSnapshot retrieves the most recent git snapshot for a session. -// Returns sql.ErrNoRows if no snapshot is found. -func (r *Repository) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { +func (r *Repository) getGitSnapshotByOrder(ctx context.Context, sessionID, orderDir string) (*models.GitSnapshot, error) { snapshot := &models.GitSnapshot{} var snapshotType string var filesJSON string var metadataJSON string - err := r.db.QueryRowContext(ctx, r.db.Rebind(` + query := fmt.Sprintf(` SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, ahead, behind, files, triggered_by, metadata, created_at FROM task_session_git_snapshots WHERE session_id = ? - ORDER BY created_at DESC LIMIT 1 - `), sessionID).Scan( + ORDER BY created_at %s LIMIT 1 + `, orderDir) + err := r.db.QueryRowContext(ctx, r.db.Rebind(query), sessionID).Scan( &snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch, &snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit, &snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy, @@ -90,43 +89,16 @@ func (r *Repository) GetLatestGitSnapshot(ctx context.Context, sessionID string) return snapshot, nil } +// GetLatestGitSnapshot retrieves the most recent git snapshot for a session. +// Returns sql.ErrNoRows if no snapshot is found. +func (r *Repository) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return r.getGitSnapshotByOrder(ctx, sessionID, "DESC") +} + // GetFirstGitSnapshot retrieves the oldest git snapshot for a session (first one created). // Returns sql.ErrNoRows if no snapshot is found. func (r *Repository) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { - snapshot := &models.GitSnapshot{} - var snapshotType string - var filesJSON string - var metadataJSON string - - err := r.db.QueryRowContext(ctx, r.db.Rebind(` - SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, - ahead, behind, files, triggered_by, metadata, created_at - FROM task_session_git_snapshots - WHERE session_id = ? - ORDER BY created_at ASC LIMIT 1 - `), sessionID).Scan( - &snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch, - &snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit, - &snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy, - &metadataJSON, &snapshot.CreatedAt, - ) - if err != nil { - return nil, err - } - - snapshot.SnapshotType = models.SnapshotType(snapshotType) - if filesJSON != "" && filesJSON != "{}" { - if err := json.Unmarshal([]byte(filesJSON), &snapshot.Files); err != nil { - return nil, fmt.Errorf("failed to deserialize git snapshot files: %w", err) - } - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &snapshot.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize git snapshot metadata: %w", err) - } - } - - return snapshot, nil + return r.getGitSnapshotByOrder(ctx, sessionID, "ASC") } // GetGitSnapshotsBySession retrieves all git snapshots for a session, ordered by created_at descending. diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 4a38dff3a4..12ffcd7732 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -123,34 +123,48 @@ func (r *Repository) ListMessagesPaginated(ctx context.Context, sessionID string if limit < 0 { limit = 0 } - sortDir := "ASC" if strings.EqualFold(opts.Sort, "desc") { sortDir = "DESC" } + cursor, err := r.resolveMessageCursor(ctx, sessionID, opts) + if err != nil { + return nil, false, err + } + query, args := buildListMessagesQuery(sessionID, opts, cursor, sortDir, limit) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...) + if err != nil { + return nil, false, err + } + defer func() { _ = rows.Close() }() + return scanMessageRows(rows, limit) +} - var cursor *models.Message +func (r *Repository) resolveMessageCursor(ctx context.Context, sessionID string, opts models.ListMessagesOptions) (*models.Message, error) { if opts.Before != "" { - var err error - cursor, err = r.GetMessage(ctx, opts.Before) + cursor, err := r.GetMessage(ctx, opts.Before) if err != nil { - return nil, false, err + return nil, err } if cursor.TaskSessionID != sessionID { - return nil, false, fmt.Errorf("message cursor not found: %s", opts.Before) + return nil, fmt.Errorf("message cursor not found: %s", opts.Before) } + return cursor, nil } if opts.After != "" { - var err error - cursor, err = r.GetMessage(ctx, opts.After) + cursor, err := r.GetMessage(ctx, opts.After) if err != nil { - return nil, false, err + return nil, err } if cursor.TaskSessionID != sessionID { - return nil, false, fmt.Errorf("message cursor not found: %s", opts.After) + return nil, fmt.Errorf("message cursor not found: %s", opts.After) } + return cursor, nil } + return nil, nil +} +func buildListMessagesQuery(sessionID string, opts models.ListMessagesOptions, cursor *models.Message, sortDir string, limit int) (string, []interface{}) { query := ` SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at FROM task_session_messages WHERE task_session_id = ?` @@ -168,43 +182,39 @@ func (r *Repository) ListMessagesPaginated(ctx context.Context, sessionID string query += " LIMIT ?" args = append(args, limit+1) } + return query, args +} - rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...) - if err != nil { - return nil, false, err - } - defer func() { _ = rows.Close() }() - +func scanMessageRows(rows interface { + Next() bool + Scan(dest ...any) error + Err() error +}, limit int) ([]*models.Message, bool, error) { var result []*models.Message for rows.Next() { message := &models.Message{} var requestsInput int var messageType string var metadataJSON string - err := rows.Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) - if err != nil { + if err := rows.Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt); err != nil { return nil, false, err } message.RequestsInput = requestsInput == 1 message.Type = models.MessageType(messageType) - if metadataJSON != "" && metadataJSON != "{}" { if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { return nil, false, fmt.Errorf("failed to deserialize message metadata: %w", err) } } - result = append(result, message) } if err := rows.Err(); err != nil { return nil, false, err } - hasMore := false if limit > 0 && len(result) > limit { - hasMore = true - result = result[:limit] + return result[:limit], true, nil } - return result, hasMore, nil + return result, false, nil } // DeleteMessage deletes a message by ID @@ -221,10 +231,7 @@ func (r *Repository) DeleteMessage(ctx context.Context, id string) error { return nil } -// GetMessageByToolCallID retrieves a tool message by session ID and tool_call_id in metadata. -// Searches all message types that have a tool_call_id in metadata (tool_call, tool_read, tool_edit, -// tool_execute, tool_search, todo, etc.). -func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { +func (r *Repository) getMessageByMetadataField(ctx context.Context, sessionID, fieldName, fieldValue, orderSuffix string) (*models.Message, error) { message := &models.Message{} var requestsInput int var messageType string @@ -233,9 +240,9 @@ func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, tool query := fmt.Sprintf(` SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at FROM task_session_messages WHERE task_session_id = ? AND %s = ? - ORDER BY created_at ASC LIMIT 1 - `, dialect.JSONExtract(drv, "metadata", "tool_call_id")) - err := r.db.QueryRowContext(ctx, r.db.Rebind(query), sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, + %s + `, dialect.JSONExtract(drv, "metadata", fieldName), orderSuffix) + err := r.db.QueryRowContext(ctx, r.db.Rebind(query), sessionID, fieldValue).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err @@ -248,28 +255,16 @@ func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, tool return message, nil } +// GetMessageByToolCallID retrieves a tool message by session ID and tool_call_id in metadata. +// Searches all message types that have a tool_call_id in metadata (tool_call, tool_read, tool_edit, +// tool_execute, tool_search, todo, etc.). +func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { + return r.getMessageByMetadataField(ctx, sessionID, "tool_call_id", toolCallID, "ORDER BY created_at ASC LIMIT 1") +} + // GetMessageByPendingID retrieves a message by session ID and pending_id in metadata func (r *Repository) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) { - message := &models.Message{} - var requestsInput int - var messageType string - var metadataJSON string - drv := r.db.DriverName() - query := fmt.Sprintf(` - SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE task_session_id = ? AND %s = ? - `, dialect.JSONExtract(drv, "metadata", "pending_id")) - err := r.db.QueryRowContext(ctx, r.db.Rebind(query), sessionID, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, - &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) - if err != nil { - return nil, err - } - message.RequestsInput = requestsInput == 1 - message.Type = models.MessageType(messageType) - if metadataJSON != "" { - _ = json.Unmarshal([]byte(metadataJSON), &message.Metadata) - } - return message, nil + return r.getMessageByMetadataField(ctx, sessionID, "pending_id", pendingID, "") } // FindMessageByPendingID finds a message by pending_id alone (without requiring session ID). diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index cbe6ad2df4..08ee2cbf19 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -48,15 +48,11 @@ func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error { return err } -// GetTurn retrieves a turn by ID -func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { +func scanTurnRow(row *sql.Row) (*models.Turn, error) { turn := &models.Turn{} var metadataJSON string var completedAt sql.NullTime - err := r.db.QueryRowContext(ctx, r.db.Rebind(` - SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at - FROM task_session_turns WHERE id = ? - `), id).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) + err := row.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) if err != nil { return nil, err } @@ -71,29 +67,24 @@ func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, erro return turn, nil } +// GetTurn retrieves a turn by ID +func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { + row := r.db.QueryRowContext(ctx, r.db.Rebind(` + SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at + FROM task_session_turns WHERE id = ? + `), id) + return scanTurnRow(row) +} + // GetActiveTurnBySessionID gets the currently active (non-completed) turn for a session func (r *Repository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) { - turn := &models.Turn{} - var metadataJSON string - var completedAt sql.NullTime - err := r.db.QueryRowContext(ctx, r.db.Rebind(` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns WHERE task_session_id = ? AND completed_at IS NULL ORDER BY started_at DESC LIMIT 1 - `), sessionID).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) - if err != nil { - return nil, err - } - if completedAt.Valid { - turn.CompletedAt = &completedAt.Time - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &turn.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize turn metadata: %w", err) - } - } - return turn, nil + `), sessionID) + return scanTurnRow(row) } // UpdateTurn updates an existing turn @@ -218,8 +209,18 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task return err } -// GetTaskSession retrieves an agent session by ID -func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { +// unmarshalSessionJSON deserializes a JSON string into dest, skipping empty/placeholder values. +func unmarshalSessionJSON(jsonStr string, dest interface{}, fieldDesc string) error { + if jsonStr == "" || jsonStr == "{}" { + return nil + } + if err := json.Unmarshal([]byte(jsonStr), dest); err != nil { + return fmt.Errorf("failed to deserialize %s: %w", fieldDesc, err) + } + return nil +} + +func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsErr string) (*models.TaskSession, error) { session := &models.TaskSession{} var state string var metadataJSON string @@ -233,177 +234,88 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas var workflowStepID sql.NullString var reviewStatus sql.NullString - err := r.db.QueryRowContext(ctx, r.db.Rebind(` + err := row.Scan( + &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, + &session.ExecutorID, &session.EnvironmentID, + &session.RepositoryID, &session.BaseBranch, + &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, + &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("%s", noRowsErr) + } + if err != nil { + return nil, err + } + + session.State = models.TaskSessionState(state) + session.IsPrimary = isPrimary == 1 + session.IsPassthrough = isPassthrough == 1 + if workflowStepID.Valid { + session.WorkflowStepID = &workflowStepID.String + } + if reviewStatus.Valid { + session.ReviewStatus = &reviewStatus.String + } + if completedAt.Valid { + session.CompletedAt = &completedAt.Time + } + if err := unmarshalSessionJSON(metadataJSON, &session.Metadata, "agent session metadata"); err != nil { + return nil, err + } + if err := unmarshalSessionJSON(agentProfileSnapshotJSON, &session.AgentProfileSnapshot, "agent profile snapshot"); err != nil { + return nil, err + } + if err := unmarshalSessionJSON(executorSnapshotJSON, &session.ExecutorSnapshot, "executor snapshot"); err != nil { + return nil, err + } + if err := unmarshalSessionJSON(environmentSnapshotJSON, &session.EnvironmentSnapshot, "environment snapshot"); err != nil { + return nil, err + } + if err := unmarshalSessionJSON(repositorySnapshotJSON, &session.RepositorySnapshot, "repository snapshot"); err != nil { + return nil, err + } + + worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + return nil, fmt.Errorf("failed to load session worktrees: %w", err) + } + session.Worktrees = worktrees + + return session, nil +} + +// GetTaskSession retrieves an agent session by ID +func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE id = ? - `), id).Scan( - &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, - &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, - &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, - &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, - ) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("agent session not found: %s", id) - } - if err != nil { - return nil, err - } - - session.State = models.TaskSessionState(state) - session.IsPrimary = isPrimary == 1 - session.IsPassthrough = isPassthrough == 1 - if workflowStepID.Valid { - session.WorkflowStepID = &workflowStepID.String - } - if reviewStatus.Valid { - session.ReviewStatus = &reviewStatus.String - } - if completedAt.Valid { - session.CompletedAt = &completedAt.Time - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) - } - } - if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) - } - } - if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) - } - } - if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) - } - } - if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) - } - } - - worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) - if err != nil { - return nil, fmt.Errorf("failed to load session worktrees: %w", err) - } - session.Worktrees = worktrees - - return session, nil + `), id) + return r.scanTaskSession(ctx, row, fmt.Sprintf("agent session not found: %s", id)) } // GetTaskSessionByTaskID retrieves the most recent agent session for a task func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { - session := &models.TaskSession{} - var state string - var metadataJSON string - var agentProfileSnapshotJSON string - var executorSnapshotJSON string - var environmentSnapshotJSON string - var repositorySnapshotJSON string - var completedAt sql.NullTime - var isPrimary int - var isPassthrough int - var workflowStepID sql.NullString - var reviewStatus sql.NullString - - err := r.db.QueryRowContext(ctx, r.db.Rebind(` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1 - `), taskID).Scan( - &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, - &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, - &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, - &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, - ) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("agent session not found for task: %s", taskID) - } - if err != nil { - return nil, err - } - - session.State = models.TaskSessionState(state) - session.IsPrimary = isPrimary == 1 - session.IsPassthrough = isPassthrough == 1 - if workflowStepID.Valid { - session.WorkflowStepID = &workflowStepID.String - } - if reviewStatus.Valid { - session.ReviewStatus = &reviewStatus.String - } - if completedAt.Valid { - session.CompletedAt = &completedAt.Time - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) - } - } - if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) - } - } - if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) - } - } - if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) - } - } - if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) - } - } - - worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) - if err != nil { - return nil, fmt.Errorf("failed to load session worktrees: %w", err) - } - session.Worktrees = worktrees - - return session, nil + `), taskID) + return r.scanTaskSession(ctx, row, fmt.Sprintf("agent session not found for task: %s", taskID)) } // GetActiveTaskSessionByTaskID retrieves the active (running/waiting) agent session for a task func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { - session := &models.TaskSession{} - var state string - var metadataJSON string - var agentProfileSnapshotJSON string - var executorSnapshotJSON string - var environmentSnapshotJSON string - var repositorySnapshotJSON string - var completedAt sql.NullTime - var isPrimary int - var isPassthrough int - var workflowStepID sql.NullString - var reviewStatus sql.NullString - - err := r.db.QueryRowContext(ctx, r.db.Rebind(` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, @@ -412,67 +324,8 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC LIMIT 1 - `), taskID).Scan( - &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, - &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, - &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, - &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, - ) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("no active agent session for task: %s", taskID) - } - if err != nil { - return nil, err - } - - session.State = models.TaskSessionState(state) - session.IsPrimary = isPrimary == 1 - session.IsPassthrough = isPassthrough == 1 - if workflowStepID.Valid { - session.WorkflowStepID = &workflowStepID.String - } - if reviewStatus.Valid { - session.ReviewStatus = &reviewStatus.String - } - if completedAt.Valid { - session.CompletedAt = &completedAt.Time - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) - } - } - if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) - } - } - if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) - } - } - if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) - } - } - if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) - } - } - - worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) - if err != nil { - return nil, fmt.Errorf("failed to load session worktrees: %w", err) - } - session.Worktrees = worktrees - - return session, nil + `), taskID) + return r.scanTaskSession(ctx, row, fmt.Sprintf("no active agent session for task: %s", taskID)) } // UpdateTaskSession updates an existing agent session @@ -699,74 +552,83 @@ func (r *Repository) HasActiveTaskSessionsByRepository(ctx context.Context, repo func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*models.TaskSession, error) { var result []*models.TaskSession for rows.Next() { - session := &models.TaskSession{} - var state string - var metadataJSON string - var agentProfileSnapshotJSON string - var executorSnapshotJSON string - var environmentSnapshotJSON string - var repositorySnapshotJSON string - var completedAt sql.NullTime - var isPrimary int - var isPassthrough int - var workflowStepID sql.NullString - var reviewStatus sql.NullString - - err := rows.Scan( - &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, - &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, - &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, - &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, - ) + session, err := scanTaskSessionRow(rows) if err != nil { return nil, err } - - session.State = models.TaskSessionState(state) - session.IsPrimary = isPrimary == 1 - session.IsPassthrough = isPassthrough == 1 - if workflowStepID.Valid { - session.WorkflowStepID = &workflowStepID.String - } - if reviewStatus.Valid { - session.ReviewStatus = &reviewStatus.String - } - if completedAt.Valid { - session.CompletedAt = &completedAt.Time - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) - } - } - if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) - } - } - if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) - } - } - if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) - } - } - if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) - } - } - result = append(result, session) } return result, rows.Err() } +// scanTaskSessionRow scans a single row into a TaskSession, applying all field mappings and JSON unmarshalling. +func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { + session := &models.TaskSession{} + var state string + var metadataJSON string + var agentProfileSnapshotJSON string + var executorSnapshotJSON string + var environmentSnapshotJSON string + var repositorySnapshotJSON string + var completedAt sql.NullTime + var isPrimary int + var isPassthrough int + var workflowStepID sql.NullString + var reviewStatus sql.NullString + + err := rows.Scan( + &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, + &session.ExecutorID, &session.EnvironmentID, + &session.RepositoryID, &session.BaseBranch, + &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, + &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, + ) + if err != nil { + return nil, err + } + + session.State = models.TaskSessionState(state) + session.IsPrimary = isPrimary == 1 + session.IsPassthrough = isPassthrough == 1 + if workflowStepID.Valid { + session.WorkflowStepID = &workflowStepID.String + } + if reviewStatus.Valid { + session.ReviewStatus = &reviewStatus.String + } + if completedAt.Valid { + session.CompletedAt = &completedAt.Time + } + + if err := unmarshalSessionSnapshots(session, metadataJSON, agentProfileSnapshotJSON, + executorSnapshotJSON, environmentSnapshotJSON, repositorySnapshotJSON); err != nil { + return nil, err + } + + return session, nil +} + +// unmarshalSessionSnapshots deserializes all JSON snapshot fields into the session struct. +func unmarshalSessionSnapshots( + session *models.TaskSession, + metadataJSON, agentProfileSnapshotJSON, executorSnapshotJSON, environmentSnapshotJSON, repositorySnapshotJSON string, +) error { + if err := unmarshalSessionJSON(metadataJSON, &session.Metadata, "agent session metadata"); err != nil { + return err + } + if err := unmarshalSessionJSON(agentProfileSnapshotJSON, &session.AgentProfileSnapshot, "agent profile snapshot"); err != nil { + return err + } + if err := unmarshalSessionJSON(executorSnapshotJSON, &session.ExecutorSnapshot, "executor snapshot"); err != nil { + return err + } + if err := unmarshalSessionJSON(environmentSnapshotJSON, &session.EnvironmentSnapshot, "environment snapshot"); err != nil { + return err + } + return unmarshalSessionJSON(repositorySnapshotJSON, &session.RepositorySnapshot, "repository snapshot") +} + // DeleteTaskSession deletes an agent session by ID func (r *Repository) DeleteTaskSession(ctx context.Context, id string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_sessions WHERE id = ?`), id) @@ -871,87 +733,15 @@ func (r *Repository) DeleteTaskSessionWorktreesBySession(ctx context.Context, se // GetPrimarySessionByTaskID retrieves the primary session for a task func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { - session := &models.TaskSession{} - var state string - var metadataJSON string - var agentProfileSnapshotJSON string - var executorSnapshotJSON string - var environmentSnapshotJSON string - var repositorySnapshotJSON string - var completedAt sql.NullTime - var isPrimary int - var isPassthrough int - var workflowStepID sql.NullString - var reviewStatus sql.NullString - - err := r.db.QueryRowContext(ctx, r.db.Rebind(` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? AND is_primary = 1 LIMIT 1 - `), taskID).Scan( - &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, - &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, - &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, - &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, - ) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("no primary session found for task: %s", taskID) - } - if err != nil { - return nil, err - } - - session.State = models.TaskSessionState(state) - session.IsPrimary = isPrimary == 1 - session.IsPassthrough = isPassthrough == 1 - if workflowStepID.Valid { - session.WorkflowStepID = &workflowStepID.String - } - if reviewStatus.Valid { - session.ReviewStatus = &reviewStatus.String - } - if completedAt.Valid { - session.CompletedAt = &completedAt.Time - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) - } - } - if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) - } - } - if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) - } - } - if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) - } - } - if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { - if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { - return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) - } - } - - worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) - if err != nil { - return nil, fmt.Errorf("failed to load session worktrees: %w", err) - } - session.Worktrees = worktrees - - return session, nil + `), taskID) + return r.scanTaskSession(ctx, row, fmt.Sprintf("no primary session found for task: %s", taskID)) } // GetPrimarySessionIDsByTaskIDs returns a map of task ID to primary session ID for the given task IDs. @@ -1109,39 +899,3 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er } return nil } - -// UpdateSessionWorkflowStep updates the workflow step for a session -func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error { - now := time.Now().UTC() - - result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE task_sessions SET workflow_step_id = ?, updated_at = ? WHERE id = ? - `), stepID, now, sessionID) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("session not found: %s", sessionID) - } - return nil -} - -// UpdateSessionReviewStatus updates the review status of a session -func (r *Repository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { - now := time.Now().UTC() - - result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE task_sessions SET review_status = ?, updated_at = ? WHERE id = ? - `), status, now, sessionID) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("session not found: %s", sessionID) - } - return nil -} diff --git a/apps/backend/internal/task/repository/sqlite/session_workflow.go b/apps/backend/internal/task/repository/sqlite/session_workflow.go new file mode 100644 index 0000000000..48e412f86c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/session_workflow.go @@ -0,0 +1,43 @@ +package sqlite + +import ( + "context" + "fmt" + "time" +) + +// UpdateSessionWorkflowStep updates the workflow step for a session +func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error { + now := time.Now().UTC() + + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_sessions SET workflow_step_id = ?, updated_at = ? WHERE id = ? + `), stepID, now, sessionID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("session not found: %s", sessionID) + } + return nil +} + +// UpdateSessionReviewStatus updates the review status of a session +func (r *Repository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { + now := time.Now().UTC() + + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_sessions SET review_status = ?, updated_at = ? WHERE id = ? + `), status, now, sessionID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("session not found: %s", sessionID) + } + return nil +} diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 7fa5c81e2b..e11e17c108 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -175,67 +175,14 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin archiveFilter = " AND archived_at IS NULL" } - var total int var rows *sql.Rows + var total int var err error if query == "" { - // No search query - use simple query - err = r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`+archiveFilter), workspaceID).Scan(&total) - if err != nil { - return nil, 0, err - } - - rows, err = r.db.QueryContext(ctx, r.db.Rebind(` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at - FROM tasks - WHERE workspace_id = ?`+archiveFilter+` - ORDER BY updated_at DESC - LIMIT ? OFFSET ? - `), workspaceID, pageSize, offset) + rows, total, err = r.queryAllTasks(ctx, workspaceID, archiveFilter, pageSize, offset) } else { - // Search query - use JOIN with repositories - searchPattern := "%" + query + "%" - like := dialect.Like(r.db.DriverName()) - - tFilter := "" - if !includeArchived { - tFilter = " AND t.archived_at IS NULL" - } - - countQuery := fmt.Sprintf(` - SELECT COUNT(DISTINCT t.id) FROM tasks t - LEFT JOIN task_repositories tr ON t.id = tr.task_id - LEFT JOIN repositories r ON tr.repository_id = r.id - WHERE t.workspace_id = ?%s - AND ( - t.title %s ? OR - t.description %s ? OR - r.name %s ? OR - r.local_path %s ? - ) - `, tFilter, like, like, like, like) - err = r.db.QueryRowContext(ctx, r.db.Rebind(countQuery), workspaceID, searchPattern, searchPattern, searchPattern, searchPattern).Scan(&total) - if err != nil { - return nil, 0, err - } - - selectQuery := fmt.Sprintf(` - SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.archived_at, t.created_at, t.updated_at - FROM tasks t - LEFT JOIN task_repositories tr ON t.id = tr.task_id - LEFT JOIN repositories r ON tr.repository_id = r.id - WHERE t.workspace_id = ?%s - AND ( - t.title %s ? OR - t.description %s ? OR - r.name %s ? OR - r.local_path %s ? - ) - ORDER BY t.updated_at DESC - LIMIT ? OFFSET ? - `, tFilter, like, like, like, like) - rows, err = r.db.QueryContext(ctx, r.db.Rebind(selectQuery), workspaceID, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset) + rows, total, err = r.searchTasks(ctx, workspaceID, query, archiveFilter, pageSize, offset, includeArchived) } if err != nil { @@ -251,6 +198,69 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin return tasks, total, nil } +// queryAllTasks fetches all tasks (no search) for a workspace with pagination. +func (r *Repository) queryAllTasks(ctx context.Context, workspaceID, archiveFilter string, pageSize, offset int) (*sql.Rows, int, error) { + var total int + err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`+archiveFilter), workspaceID).Scan(&total) + if err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at + FROM tasks + WHERE workspace_id = ?`+archiveFilter+` + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + `), workspaceID, pageSize, offset) + return rows, total, err +} + +// searchTasks fetches tasks matching a search query for a workspace with pagination. +func (r *Repository) searchTasks(ctx context.Context, workspaceID, query, archiveFilter string, pageSize, offset int, includeArchived bool) (*sql.Rows, int, error) { + searchPattern := "%" + query + "%" + like := dialect.Like(r.db.DriverName()) + + tFilter := "" + if !includeArchived { + tFilter = " AND t.archived_at IS NULL" + } + + countQuery := fmt.Sprintf(` + SELECT COUNT(DISTINCT t.id) FROM tasks t + LEFT JOIN task_repositories tr ON t.id = tr.task_id + LEFT JOIN repositories r ON tr.repository_id = r.id + WHERE t.workspace_id = ?%s + AND ( + t.title %s ? OR + t.description %s ? OR + r.name %s ? OR + r.local_path %s ? + ) + `, tFilter, like, like, like, like) + var total int + if err := r.db.QueryRowContext(ctx, r.db.Rebind(countQuery), workspaceID, searchPattern, searchPattern, searchPattern, searchPattern).Scan(&total); err != nil { + return nil, 0, err + } + + selectQuery := fmt.Sprintf(` + SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.archived_at, t.created_at, t.updated_at + FROM tasks t + LEFT JOIN task_repositories tr ON t.id = tr.task_id + LEFT JOIN repositories r ON tr.repository_id = r.id + WHERE t.workspace_id = ?%s + AND ( + t.title %s ? OR + t.description %s ? OR + r.name %s ? OR + r.local_path %s ? + ) + ORDER BY t.updated_at DESC + LIMIT ? OFFSET ? + `, tFilter, like, like, like, like) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(selectQuery), workspaceID, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset) + return rows, total, err +} + // scanTasks is a helper to scan task rows func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { var result []*models.Task diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go deleted file mode 100644 index eb72e4b590..0000000000 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ /dev/null @@ -1,1390 +0,0 @@ -package repository - -import ( - "context" - "path/filepath" - "testing" - "time" - - "github.com/jmoiron/sqlx" - "github.com/kandev/kandev/internal/worktree" - "github.com/kandev/kandev/internal/db" - "github.com/kandev/kandev/internal/task/models" - "github.com/kandev/kandev/internal/task/repository/sqlite" - v1 "github.com/kandev/kandev/pkg/api/v1" -) - -func createTestSQLiteRepo(t *testing.T) (*sqlite.Repository, func()) { - t.Helper() - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.db") - - dbConn, err := db.OpenSQLite(dbPath) - if err != nil { - t.Fatalf("failed to open SQLite database: %v", err) - } - sqlxDB := sqlx.NewDb(dbConn, "sqlite3") - repo, err := sqlite.NewWithDB(sqlxDB) - if err != nil { - t.Fatalf("failed to create SQLite repository: %v", err) - } - if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { - t.Fatalf("failed to init worktree store: %v", err) - } - - cleanup := func() { - if err := sqlxDB.Close(); err != nil { - t.Errorf("failed to close sqlite db: %v", err) - } - if err := repo.Close(); err != nil { - t.Errorf("failed to close repo: %v", err) - } - } - - return repo, cleanup -} - -func TestNewSQLiteRepositoryWithDB(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - - if repo == nil { - t.Fatal("expected non-nil repository") - } - if repo.DB() == nil { - t.Error("expected db to be initialized") - } -} - -func TestSQLiteRepository_Close(t *testing.T) { - repo, _ := createTestSQLiteRepo(t) - err := repo.Close() - if err != nil { - t.Errorf("expected no error on close, got %v", err) - } -} - -func TestSQLiteRepository_SeedsDefaultWorkspace(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - workspaces, err := repo.ListWorkspaces(ctx) - if err != nil { - t.Fatalf("failed to list workspaces: %v", err) - } - if len(workspaces) != 1 { - t.Fatalf("expected 1 workspace, got %d", len(workspaces)) - } - if workspaces[0].Name != "Default Workspace" { - t.Errorf("expected Default Workspace, got %s", workspaces[0].Name) - } - - workflows, err := repo.ListWorkflows(ctx, workspaces[0].ID) - if err != nil { - t.Fatalf("failed to list workflows: %v", err) - } - if len(workflows) != 1 { - t.Fatalf("expected 1 workflow, got %d", len(workflows)) - } - if workflows[0].Name != "Development" { - t.Errorf("expected Development workflow, got %s", workflows[0].Name) - } - // Note: workflow steps are now managed by the workflow repository, not the task repository -} - -// Workflow CRUD tests - -func TestSQLiteRepository_WorkflowCRUD(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - workspace := &models.Workspace{ID: "ws-1", Name: "Workspace"} - if err := repo.CreateWorkspace(ctx, workspace); err != nil { - t.Fatalf("failed to create workspace: %v", err) - } - - // Create - workflow := &models.Workflow{WorkspaceID: workspace.ID, Name: "Test Workflow", Description: "A test workflow"} - if err := repo.CreateWorkflow(ctx, workflow); err != nil { - t.Fatalf("failed to create workflow: %v", err) - } - if workflow.ID == "" { - t.Error("expected workflow ID to be set") - } - if workflow.CreatedAt.IsZero() { - t.Error("expected CreatedAt to be set") - } - - // Get - retrieved, err := repo.GetWorkflow(ctx, workflow.ID) - if err != nil { - t.Fatalf("failed to get workflow: %v", err) - } - if retrieved.Name != "Test Workflow" { - t.Errorf("expected name 'Test Workflow', got %s", retrieved.Name) - } - - // Update - workflow.Name = "Updated Name" - if err := repo.UpdateWorkflow(ctx, workflow); err != nil { - t.Fatalf("failed to update workflow: %v", err) - } - retrieved, _ = repo.GetWorkflow(ctx, workflow.ID) - if retrieved.Name != "Updated Name" { - t.Errorf("expected name 'Updated Name', got %s", retrieved.Name) - } - - // Delete - if err := repo.DeleteWorkflow(ctx, workflow.ID); err != nil { - t.Fatalf("failed to delete workflow: %v", err) - } - _, err = repo.GetWorkflow(ctx, workflow.ID) - if err == nil { - t.Error("expected workflow to be deleted") - } -} - -func TestSQLiteRepository_WorkflowNotFound(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - _, err := repo.GetWorkflow(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent workflow") - } - - err = repo.UpdateWorkflow(ctx, &models.Workflow{ID: "nonexistent", Name: "Test"}) - if err == nil { - t.Error("expected error for updating nonexistent workflow") - } - - err = repo.DeleteWorkflow(ctx, "nonexistent") - if err == nil { - t.Error("expected error for deleting nonexistent workflow") - } -} - -func TestSQLiteRepository_ListWorkflows(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) - - _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Workflow 1"}) - _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Workflow 2"}) - _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-3", WorkspaceID: "ws-2", Name: "Workflow 3"}) - - workflows, err := repo.ListWorkflows(ctx, "ws-1") - if err != nil { - t.Fatalf("failed to list workflows: %v", err) - } - if len(workflows) != 2 { - t.Errorf("expected 2 workflows, got %d", len(workflows)) - } -} - -// Task CRUD tests - -func TestSQLiteRepository_TaskCRUD(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workspace and workflow for foreign keys - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - - // Create task (workflow steps are managed by workflow repository) - task := &models.Task{ - WorkspaceID: "ws-1", - WorkflowID: "wf-123", - WorkflowStepID: "step-123", - Title: "Test Task", - Description: "A test task", - State: v1.TaskStateTODO, - Priority: 5, - Metadata: map[string]interface{}{"key": "value"}, - } - if err := repo.CreateTask(ctx, task); err != nil { - t.Fatalf("failed to create task: %v", err) - } - if task.ID == "" { - t.Error("expected task ID to be set") - } - - // Get - retrieved, err := repo.GetTask(ctx, task.ID) - if err != nil { - t.Fatalf("failed to get task: %v", err) - } - if retrieved.Title != "Test Task" { - t.Errorf("expected title 'Test Task', got %s", retrieved.Title) - } - if retrieved.Metadata["key"] != "value" { - t.Errorf("expected metadata key 'value', got %v", retrieved.Metadata["key"]) - } - - // Update - task.Title = "Updated Task" - if err := repo.UpdateTask(ctx, task); err != nil { - t.Fatalf("failed to update task: %v", err) - } - retrieved, _ = repo.GetTask(ctx, task.ID) - if retrieved.Title != "Updated Task" { - t.Errorf("expected title 'Updated Task', got %s", retrieved.Title) - } - - // Delete - if err := repo.DeleteTask(ctx, task.ID); err != nil { - t.Fatalf("failed to delete task: %v", err) - } - _, err = repo.GetTask(ctx, task.ID) - if err == nil { - t.Error("expected task to be deleted") - } -} - -func TestSQLiteRepository_TaskNotFound(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - _, err := repo.GetTask(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent task") - } - - err = repo.UpdateTask(ctx, &models.Task{ID: "nonexistent", Title: "Test"}) - if err == nil { - t.Error("expected error for updating nonexistent task") - } - - err = repo.DeleteTask(ctx, "nonexistent") - if err == nil { - t.Error("expected error for deleting nonexistent task") - } -} - -func TestSQLiteRepository_UpdateTaskState(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workspace, workflow, and task - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO} - _ = repo.CreateTask(ctx, task) - - err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress) - if err != nil { - t.Fatalf("failed to update task state: %v", err) - } - - retrieved, _ := repo.GetTask(ctx, "task-123") - if retrieved.State != v1.TaskStateInProgress { - t.Errorf("expected state IN_PROGRESS, got %s", retrieved.State) - } -} - -func TestSQLiteRepository_UpdateTaskStateNotFound(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - err := repo.UpdateTaskState(ctx, "nonexistent", v1.TaskStateInProgress) - if err == nil { - t.Error("expected error for nonexistent task") - } -} - -func TestSQLiteRepository_ListTasks(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workspace and workflow - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2"}) - - tasks, err := repo.ListTasks(ctx, "wf-123") - if err != nil { - t.Fatalf("failed to list tasks: %v", err) - } - if len(tasks) != 2 { - t.Errorf("expected 2 tasks, got %d", len(tasks)) - } -} - -func TestSQLiteRepository_ListTasksByWorkflowStep(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workspace and workflow - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - - // Tasks with different workflow steps - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task 1"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task 2"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-2", Title: "Task 3"}) - - tasks, err := repo.ListTasksByWorkflowStep(ctx, "step-1") - if err != nil { - t.Fatalf("failed to list tasks by workflow step: %v", err) - } - if len(tasks) != 2 { - t.Errorf("expected 2 tasks for step-1, got %d", len(tasks)) - } -} - -func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workspaces and workflow - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) - workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - - // Create tasks in workspace 1 - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task One"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task Two"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task Three"}) - // Create task in workspace 2 - workflow2 := &models.Workflow{ID: "wf-456", WorkspaceID: "ws-2", Name: "Test Workflow 2"} - _ = repo.CreateWorkflow(ctx, workflow2) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-4", WorkspaceID: "ws-2", WorkflowID: "wf-456", WorkflowStepID: "step-2", Title: "Task Four"}) - - // Test basic listing without search - tasks, total, err := repo.ListTasksByWorkspace(ctx,"ws-1", "", 1, 10, false) - if err != nil { - t.Fatalf("failed to list tasks by workspace: %v", err) - } - if total != 3 { - t.Errorf("expected total 3 tasks for ws-1, got %d", total) - } - if len(tasks) != 3 { - t.Errorf("expected 3 tasks returned, got %d", len(tasks)) - } - - // Test pagination - tasks, total, err = repo.ListTasksByWorkspace(ctx,"ws-1", "", 1, 2, false) - if err != nil { - t.Fatalf("failed to list tasks with pagination: %v", err) - } - if total != 3 { - t.Errorf("expected total 3 tasks, got %d", total) - } - if len(tasks) != 2 { - t.Errorf("expected 2 tasks per page, got %d", len(tasks)) - } - - // Test page 2 - tasksPage2, _, err := repo.ListTasksByWorkspace(ctx,"ws-1", "", 2, 2, false) - if err != nil { - t.Fatalf("failed to list tasks page 2: %v", err) - } - if len(tasksPage2) != 1 { - t.Errorf("expected 1 task on page 2, got %d", len(tasksPage2)) - } -} - -func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workspace, workflow, and repository - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - repository := &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "MyProject", LocalPath: "/home/user/projects/myproject"} - _ = repo.CreateRepository(ctx, repository) - - // Create tasks with different titles and descriptions - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Fix authentication bug", Description: "Users cannot login"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Add new feature", Description: "Implement dark mode"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Refactor codebase", Description: "Clean up authentication module"}) - - // Link task-1 to the repository - _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-1", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main"}) - - // Test search by title - _, totalAuth, err := repo.ListTasksByWorkspace(ctx,"ws-1", "authentication", 1, 10, false) - if err != nil { - t.Fatalf("failed to search tasks by title: %v", err) - } - if totalAuth != 2 { - t.Errorf("expected 2 tasks matching 'authentication', got %d", totalAuth) - } - - // Test search by description - tasksDarkMode, totalDarkMode, err := repo.ListTasksByWorkspace(ctx,"ws-1", "dark mode", 1, 10, false) - if err != nil { - t.Fatalf("failed to search tasks by description: %v", err) - } - if totalDarkMode != 1 { - t.Errorf("expected 1 task matching 'dark mode', got %d", totalDarkMode) - } - if len(tasksDarkMode) != 1 || tasksDarkMode[0].ID != "task-2" { - t.Errorf("expected task-2 to be returned") - } - - // Test search by repository name - tasksRepo, totalRepo, err := repo.ListTasksByWorkspace(ctx,"ws-1", "MyProject", 1, 10, false) - if err != nil { - t.Fatalf("failed to search tasks by repository name: %v", err) - } - if totalRepo != 1 { - t.Errorf("expected 1 task matching repository 'MyProject', got %d", totalRepo) - } - if len(tasksRepo) != 1 || tasksRepo[0].ID != "task-1" { - t.Errorf("expected task-1 to be returned") - } - - // Test search by repository local_path - _, totalPath, err := repo.ListTasksByWorkspace(ctx,"ws-1", "myproject", 1, 10, false) - if err != nil { - t.Fatalf("failed to search tasks by repository path: %v", err) - } - if totalPath != 1 { - t.Errorf("expected 1 task matching repository path 'myproject', got %d", totalPath) - } - - // Test search with no results - tasksNone, totalNone, err := repo.ListTasksByWorkspace(ctx,"ws-1", "nonexistent", 1, 10, false) - if err != nil { - t.Fatalf("failed to search tasks with no results: %v", err) - } - if totalNone != 0 { - t.Errorf("expected 0 tasks matching 'nonexistent', got %d", totalNone) - } - if len(tasksNone) != 0 { - t.Errorf("expected empty tasks slice, got %d tasks", len(tasksNone)) - } -} - -func TestSQLiteRepository_Persistence(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "persistence_test.db") - ctx := context.Background() - - // Create repository and add data - dbConn1, err := db.OpenSQLite(dbPath) - if err != nil { - t.Fatalf("failed to open SQLite database: %v", err) - } - sqlxDB1 := sqlx.NewDb(dbConn1, "sqlite3") - repo1, err := sqlite.NewWithDB(sqlxDB1) - if err != nil { - t.Fatalf("failed to create first repository: %v", err) - } - - _ = repo1.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - workflow := &models.Workflow{ID: "persist-wf", WorkspaceID: "ws-1", Name: "Persistent Workflow"} - _ = repo1.CreateWorkflow(ctx, workflow) - if err := repo1.Close(); err != nil { - t.Fatalf("failed to close repo: %v", err) - } - if err := sqlxDB1.Close(); err != nil { - t.Fatalf("failed to close sqlite db: %v", err) - } - - // Reopen repository and verify data persisted - dbConn2, err := db.OpenSQLite(dbPath) - if err != nil { - t.Fatalf("failed to open SQLite database: %v", err) - } - sqlxDB2 := sqlx.NewDb(dbConn2, "sqlite3") - repo2, err := sqlite.NewWithDB(sqlxDB2) - if err != nil { - t.Fatalf("failed to create second repository: %v", err) - } - defer func() { - if err := sqlxDB2.Close(); err != nil { - t.Errorf("failed to close sqlite db: %v", err) - } - if err := repo2.Close(); err != nil { - t.Errorf("failed to close repo: %v", err) - } - }() - - retrieved, err := repo2.GetWorkflow(ctx, "persist-wf") - if err != nil { - t.Fatalf("failed to get workflow after reopen: %v", err) - } - if retrieved.Name != "Persistent Workflow" { - t.Errorf("expected name 'Persistent Workflow', got %s", retrieved.Name) - } -} - -// Message CRUD tests - -func setupSQLiteTestSession(ctx context.Context, repo *sqlite.Repository, taskID, sessionID string) string { - session := &models.TaskSession{ - ID: sessionID, - TaskID: taskID, - AgentProfileID: "profile-123", - State: models.TaskSessionStateStarting, - } - _ = repo.CreateTaskSession(ctx, session) - return session.ID -} - -func setupSQLiteTestTurn(ctx context.Context, repo *sqlite.Repository, sessionID, taskID, turnID string) string { - now := time.Now() - turn := &models.Turn{ - ID: turnID, - TaskSessionID: sessionID, - TaskID: taskID, - StartedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - _ = repo.CreateTurn(ctx, turn) - return turn.ID -} - -func TestSQLiteRepository_MessageCRUD(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workflow first (required for foreign key constraints) - workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - - // Create a task first - task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") - turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") - - // Create comment - comment := &models.Message{ - TaskSessionID: sessionID, - TaskID: "task-123", - TurnID: turnID, - AuthorType: models.MessageAuthorUser, - AuthorID: "user-123", - Content: "This is a test comment", - RequestsInput: false, - } - if err := repo.CreateMessage(ctx, comment); err != nil { - t.Fatalf("failed to create comment: %v", err) - } - if comment.ID == "" { - t.Error("expected comment ID to be set") - } - if comment.CreatedAt.IsZero() { - t.Error("expected CreatedAt to be set") - } - - // Get comment - retrieved, err := repo.GetMessage(ctx, comment.ID) - if err != nil { - t.Fatalf("failed to get comment: %v", err) - } - if retrieved.Content != "This is a test comment" { - t.Errorf("expected content 'This is a test comment', got %s", retrieved.Content) - } - if retrieved.AuthorType != models.MessageAuthorUser { - t.Errorf("expected author type 'user', got %s", retrieved.AuthorType) - } - - // Delete comment - if err := repo.DeleteMessage(ctx, comment.ID); err != nil { - t.Fatalf("failed to delete comment: %v", err) - } - _, err = repo.GetMessage(ctx, comment.ID) - if err == nil { - t.Error("expected comment to be deleted") - } -} - -func TestSQLiteRepository_MessageNotFound(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - _, err := repo.GetMessage(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent comment") - } - - err = repo.DeleteMessage(ctx, "nonexistent") - if err == nil { - t.Error("expected error for deleting nonexistent comment") - } -} - -func TestSQLiteRepository_ListMessages(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workflow first (required for foreign key constraints) - workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - - // Create tasks - task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - task2 := &models.Task{ID: "task-456", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task 2"} - _ = repo.CreateTask(ctx, task2) - sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") - session2ID := setupSQLiteTestSession(ctx, repo, task2.ID, "session-456") - turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") - turn2ID := setupSQLiteTestTurn(ctx, repo, session2ID, task2.ID, "turn-456") - - // Create multiple comments - _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-1", TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, AuthorType: models.MessageAuthorUser, Content: "Comment 1"}) - _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-2", TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, AuthorType: models.MessageAuthorAgent, Content: "Comment 2"}) - _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-3", TaskSessionID: session2ID, TaskID: "task-456", TurnID: turn2ID, AuthorType: models.MessageAuthorUser, Content: "Comment 3"}) - - comments, err := repo.ListMessages(ctx, sessionID) - if err != nil { - t.Fatalf("failed to list comments: %v", err) - } - if len(comments) != 2 { - t.Errorf("expected 2 comments for task-123, got %d", len(comments)) - } -} - -func TestSQLiteRepository_ListMessagesPagination(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") - turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") - - baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - _ = repo.CreateMessage(ctx, &models.Message{ - ID: "comment-1", - TaskSessionID: sessionID, - TaskID: "task-123", - TurnID: turnID, - AuthorType: models.MessageAuthorUser, - Content: "Comment 1", - CreatedAt: baseTime.Add(-2 * time.Minute), - }) - _ = repo.CreateMessage(ctx, &models.Message{ - ID: "comment-2", - TaskSessionID: sessionID, - TaskID: "task-123", - TurnID: turnID, - AuthorType: models.MessageAuthorUser, - Content: "Comment 2", - CreatedAt: baseTime.Add(-1 * time.Minute), - }) - _ = repo.CreateMessage(ctx, &models.Message{ - ID: "comment-3", - TaskSessionID: sessionID, - TaskID: "task-123", - TurnID: turnID, - AuthorType: models.MessageAuthorUser, - Content: "Comment 3", - CreatedAt: baseTime, - }) - - comments, hasMore, err := repo.ListMessagesPaginated(ctx, sessionID, models.ListMessagesOptions{ - Limit: 2, - Sort: "desc", - }) - if err != nil { - t.Fatalf("failed to list comments: %v", err) - } - if len(comments) != 2 { - t.Fatalf("expected 2 comments, got %d", len(comments)) - } - if !hasMore { - t.Error("expected hasMore to be true") - } - if comments[0].ID != "comment-3" { - t.Errorf("expected newest comment first, got %s", comments[0].ID) - } -} - -func TestSQLiteRepository_MessageWithRequestsInput(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workflow first (required for foreign key constraints) - workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - - // Create a task - task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") - turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") - - // Create agent comment requesting input - comment := &models.Message{ - TaskSessionID: sessionID, - TaskID: "task-123", - TurnID: turnID, - AuthorType: models.MessageAuthorAgent, - AuthorID: "agent-123", - Content: "What should I do next?", - RequestsInput: true, - } - if err := repo.CreateMessage(ctx, comment); err != nil { - t.Fatalf("failed to create comment: %v", err) - } - - retrieved, _ := repo.GetMessage(ctx, comment.ID) - if !retrieved.RequestsInput { - t.Error("expected RequestsInput to be true") - } -} - -// TaskSession CRUD tests - -func TestSQLiteRepository_TaskSessionCRUD(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workflow and task first (required for foreign key constraints) - workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - - // Create agent session - session := &models.TaskSession{ - TaskID: "task-123", - AgentExecutionID: "execution-abc", - ContainerID: "container-xyz", - AgentProfileID: "profile-123", - ExecutorID: "executor-1", - EnvironmentID: "env-1", - State: models.TaskSessionStateStarting, - Metadata: map[string]interface{}{"key": "value"}, - } - if err := repo.CreateTaskSession(ctx, session); err != nil { - t.Fatalf("failed to create agent session: %v", err) - } - if session.ID == "" { - t.Error("expected session ID to be set") - } - if session.StartedAt.IsZero() { - t.Error("expected StartedAt to be set") - } - if session.UpdatedAt.IsZero() { - t.Error("expected UpdatedAt to be set") - } - - // Get agent session - retrieved, err := repo.GetTaskSession(ctx, session.ID) - if err != nil { - t.Fatalf("failed to get agent session: %v", err) - } - if retrieved.TaskID != "task-123" { - t.Errorf("expected TaskID 'task-123', got %s", retrieved.TaskID) - } - if retrieved.AgentProfileID != "profile-123" { - t.Errorf("expected AgentProfileID 'profile-123', got %s", retrieved.AgentProfileID) - } - if retrieved.State != models.TaskSessionStateStarting { - t.Errorf("expected state 'starting', got %s", retrieved.State) - } - if retrieved.Metadata["key"] != "value" { - t.Errorf("expected metadata key 'value', got %v", retrieved.Metadata["key"]) - } - - // Update agent session - session.State = models.TaskSessionStateRunning - if err := repo.UpdateTaskSession(ctx, session); err != nil { - t.Fatalf("failed to update agent session: %v", err) - } - retrieved, _ = repo.GetTaskSession(ctx, session.ID) - if retrieved.State != models.TaskSessionStateRunning { - t.Errorf("expected state 'running', got %s", retrieved.State) - } - - // Delete agent session - if err := repo.DeleteTaskSession(ctx, session.ID); err != nil { - t.Fatalf("failed to delete agent session: %v", err) - } - _, err = repo.GetTaskSession(ctx, session.ID) - if err == nil { - t.Error("expected agent session to be deleted") - } -} - -func TestSQLiteRepository_TaskSessionNotFound(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - _, err := repo.GetTaskSession(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent agent session") - } - - err = repo.UpdateTaskSession(ctx, &models.TaskSession{ID: "nonexistent", TaskID: "task-123"}) - if err == nil { - t.Error("expected error for updating nonexistent agent session") - } - - err = repo.DeleteTaskSession(ctx, "nonexistent") - if err == nil { - t.Error("expected error for deleting nonexistent agent session") - } -} - -func TestSQLiteRepository_TaskSessionByTaskID(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workflow and task - workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - - // Create multiple sessions for the same task (simulating session history) - session1 := &models.TaskSession{ - ID: "session-1", - TaskID: "task-123", - AgentProfileID: "profile-1", - State: models.TaskSessionStateCompleted, - } - _ = repo.CreateTaskSession(ctx, session1) - - session2 := &models.TaskSession{ - ID: "session-2", - TaskID: "task-123", - AgentProfileID: "profile-2", - State: models.TaskSessionStateRunning, - } - _ = repo.CreateTaskSession(ctx, session2) - - // GetTaskSessionByTaskID should return the most recent session - retrieved, err := repo.GetTaskSessionByTaskID(ctx, "task-123") - if err != nil { - t.Fatalf("failed to get agent session by task ID: %v", err) - } - if retrieved.ID != "session-2" { - t.Errorf("expected session-2 (most recent), got %s", retrieved.ID) - } - - // GetActiveTaskSessionByTaskID should return the active session - active, err := repo.GetActiveTaskSessionByTaskID(ctx, "task-123") - if err != nil { - t.Fatalf("failed to get active agent session by task ID: %v", err) - } - if active.ID != "session-2" { - t.Errorf("expected session-2 (active), got %s", active.ID) - } - if active.State != models.TaskSessionStateRunning { - t.Errorf("expected state 'running', got %s", active.State) - } - - // Test when no active session exists - session2.State = models.TaskSessionStateCompleted - _ = repo.UpdateTaskSession(ctx, session2) - - _, err = repo.GetActiveTaskSessionByTaskID(ctx, "task-123") - if err == nil { - t.Error("expected error when no active session exists") - } - - // Test for nonexistent task - _, err = repo.GetTaskSessionByTaskID(ctx, "nonexistent-task") - if err == nil { - t.Error("expected error for nonexistent task") - } -} - -func TestSQLiteRepository_ListTaskSessions(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workflow and tasks - workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - task1 := &models.Task{ID: "task-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1"} - _ = repo.CreateTask(ctx, task1) - task2 := &models.Task{ID: "task-2", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2"} - _ = repo.CreateTask(ctx, task2) - - // Create sessions for different tasks - _ = repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-1", TaskID: "task-1", AgentProfileID: "profile-1", State: models.TaskSessionStateCompleted}) - _ = repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-2", TaskID: "task-1", AgentProfileID: "profile-1", State: models.TaskSessionStateRunning}) - _ = repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-3", TaskID: "task-2", AgentProfileID: "profile-2", State: models.TaskSessionStateStarting}) - - // List sessions for task-1 - sessions, err := repo.ListTaskSessions(ctx, "task-1") - if err != nil { - t.Fatalf("failed to list agent sessions: %v", err) - } - if len(sessions) != 2 { - t.Errorf("expected 2 sessions for task-1, got %d", len(sessions)) - } - - // List all active sessions - activeSessions, err := repo.ListActiveTaskSessions(ctx) - if err != nil { - t.Fatalf("failed to list active agent sessions: %v", err) - } - if len(activeSessions) != 2 { - t.Errorf("expected 2 active sessions, got %d", len(activeSessions)) - } - - // Verify only active statuses are returned - for _, s := range activeSessions { - if s.State != models.TaskSessionStateStarting && s.State != models.TaskSessionStateRunning && s.State != models.TaskSessionStateWaitingForInput { - t.Errorf("expected active state, got %s", s.State) - } - } -} - -func TestSQLiteRepository_UpdateTaskSessionState(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create workflow and task - workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - - // Create an agent session - session := &models.TaskSession{ - ID: "session-123", - TaskID: "task-123", - AgentProfileID: "profile-1", - State: models.TaskSessionStateStarting, - } - _ = repo.CreateTaskSession(ctx, session) - - // Update to running status - err := repo.UpdateTaskSessionState(ctx, "session-123", models.TaskSessionStateRunning, "") - if err != nil { - t.Fatalf("failed to update agent session status: %v", err) - } - retrieved, _ := repo.GetTaskSession(ctx, "session-123") - if retrieved.State != models.TaskSessionStateRunning { - t.Errorf("expected state 'running', got %s", retrieved.State) - } - if retrieved.CompletedAt != nil { - t.Error("expected CompletedAt to be nil for running status") - } - - // Update to completed status (should set CompletedAt) - err = repo.UpdateTaskSessionState(ctx, "session-123", models.TaskSessionStateCompleted, "") - if err != nil { - t.Fatalf("failed to update agent session status to completed: %v", err) - } - retrieved, _ = repo.GetTaskSession(ctx, "session-123") - if retrieved.State != models.TaskSessionStateCompleted { - t.Errorf("expected state 'completed', got %s", retrieved.State) - } - if retrieved.CompletedAt == nil { - t.Error("expected CompletedAt to be set for completed status") - } - - // Test failed status with error message - session2 := &models.TaskSession{ - ID: "session-456", - TaskID: "task-123", - AgentProfileID: "profile-1", - State: models.TaskSessionStateRunning, - } - _ = repo.CreateTaskSession(ctx, session2) - - err = repo.UpdateTaskSessionState(ctx, "session-456", models.TaskSessionStateFailed, "connection timeout") - if err != nil { - t.Fatalf("failed to update agent session status to failed: %v", err) - } - retrieved, _ = repo.GetTaskSession(ctx, "session-456") - if retrieved.State != models.TaskSessionStateFailed { - t.Errorf("expected state 'failed', got %s", retrieved.State) - } - if retrieved.ErrorMessage != "connection timeout" { - t.Errorf("expected error message 'connection timeout', got %s", retrieved.ErrorMessage) - } - if retrieved.CompletedAt == nil { - t.Error("expected CompletedAt to be set for failed status") - } - - // Test stopped status - session3 := &models.TaskSession{ - ID: "session-789", - TaskID: "task-123", - AgentProfileID: "profile-1", - State: models.TaskSessionStateRunning, - } - _ = repo.CreateTaskSession(ctx, session3) - - err = repo.UpdateTaskSessionState(ctx, "session-789", models.TaskSessionStateCancelled, "") - if err != nil { - t.Fatalf("failed to update agent session status to stopped: %v", err) - } - retrieved, _ = repo.GetTaskSession(ctx, "session-789") - if retrieved.State != models.TaskSessionStateCancelled { - t.Errorf("expected state 'cancelled', got %s", retrieved.State) - } - if retrieved.CompletedAt == nil { - t.Error("expected CompletedAt to be set for stopped status") - } - - // Test nonexistent session - err = repo.UpdateTaskSessionState(ctx, "nonexistent", models.TaskSessionStateRunning, "") - if err == nil { - t.Error("expected error for updating nonexistent session status") - } -} - -func TestSQLiteRepository_CompleteRunningToolCallsForTurn(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Setup - workflow := &models.Workflow{ID: "wf-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - task := &models.Task{ID: "task-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-1") - turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-1") - - // Create a tool call message with status "running" - runningTool := &models.Message{ - ID: "msg-running-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, - AuthorType: models.MessageAuthorAgent, Content: "Running tool", - Type: models.MessageTypeToolCall, - Metadata: map[string]interface{}{"tool_call_id": "tc-1", "status": "running"}, - } - // Create a tool call message already "complete" - completeTool := &models.Message{ - ID: "msg-complete-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, - AuthorType: models.MessageAuthorAgent, Content: "Complete tool", - Type: models.MessageTypeToolCall, - Metadata: map[string]interface{}{"tool_call_id": "tc-2", "status": "complete"}, - } - // Create a regular message (no tool_call_id) with status "running" — should NOT be affected - regularMsg := &models.Message{ - ID: "msg-regular-1", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, - AuthorType: models.MessageAuthorAgent, Content: "Regular message", - Type: models.MessageTypeMessage, - Metadata: map[string]interface{}{"status": "running"}, - } - // Create a second running tool call - runningTool2 := &models.Message{ - ID: "msg-running-2", TaskSessionID: sessionID, TaskID: task.ID, TurnID: turnID, - AuthorType: models.MessageAuthorAgent, Content: "Running tool 2", - Type: models.MessageTypeToolCall, - Metadata: map[string]interface{}{"tool_call_id": "tc-3", "status": "running"}, - } - - for _, msg := range []*models.Message{runningTool, completeTool, regularMsg, runningTool2} { - if err := repo.CreateMessage(ctx, msg); err != nil { - t.Fatalf("failed to create message %s: %v", msg.ID, err) - } - } - - // Execute - affected, err := repo.CompleteRunningToolCallsForTurn(ctx, turnID) - if err != nil { - t.Fatalf("CompleteRunningToolCallsForTurn failed: %v", err) - } - - // Should have updated exactly 2 running tool call messages - if affected != 2 { - t.Errorf("expected 2 affected rows, got %d", affected) - } - - // Verify running tool calls are now "complete" - msg1, _ := repo.GetMessage(ctx, "msg-running-1") - if msg1.Metadata["status"] != "complete" { - t.Errorf("expected msg-running-1 status 'complete', got %v", msg1.Metadata["status"]) - } - msg2, _ := repo.GetMessage(ctx, "msg-running-2") - if msg2.Metadata["status"] != "complete" { - t.Errorf("expected msg-running-2 status 'complete', got %v", msg2.Metadata["status"]) - } - - // Verify already-complete tool call is unchanged - msg3, _ := repo.GetMessage(ctx, "msg-complete-1") - if msg3.Metadata["status"] != "complete" { - t.Errorf("expected msg-complete-1 status 'complete', got %v", msg3.Metadata["status"]) - } - - // Verify regular message (no tool_call_id) was NOT affected - msg4, _ := repo.GetMessage(ctx, "msg-regular-1") - if msg4.Metadata["status"] != "running" { - t.Errorf("expected msg-regular-1 status 'running' (unchanged), got %v", msg4.Metadata["status"]) - } - - // Running again should affect 0 rows - affected2, err := repo.CompleteRunningToolCallsForTurn(ctx, turnID) - if err != nil { - t.Fatalf("second CompleteRunningToolCallsForTurn failed: %v", err) - } - if affected2 != 0 { - t.Errorf("expected 0 affected rows on second call, got %d", affected2) - } -} - -func TestSQLiteRepository_ArchiveTask(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task One"}) - - // Archive the task - err := repo.ArchiveTask(ctx, "task-1") - if err != nil { - t.Fatalf("failed to archive task: %v", err) - } - - // Verify archived_at is set - task, err := repo.GetTask(ctx, "task-1") - if err != nil { - t.Fatalf("failed to get task: %v", err) - } - if task.ArchivedAt == nil { - t.Fatal("expected archived_at to be set") - } - - // Archive again should fail - err = repo.ArchiveTask(ctx, "task-1") - // The repo method doesn't check for already archived, but it still succeeds (updates the timestamp) - if err != nil { - t.Fatalf("unexpected error archiving already-archived task: %v", err) - } - - // Archive non-existent task should fail - err = repo.ArchiveTask(ctx, "nonexistent") - if err == nil { - t.Error("expected error when archiving nonexistent task") - } -} - -func TestSQLiteRepository_ArchiveTask_ExcludesFromListTasks(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task One"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task Two"}) - - // Archive task-1 - _ = repo.ArchiveTask(ctx, "task-1") - - // ListTasks should exclude archived tasks - tasks, err := repo.ListTasks(ctx, "wf-1") - if err != nil { - t.Fatalf("failed to list tasks: %v", err) - } - if len(tasks) != 1 { - t.Errorf("expected 1 task (excluding archived), got %d", len(tasks)) - } - if tasks[0].ID != "task-2" { - t.Errorf("expected task-2, got %s", tasks[0].ID) - } - - // ListTasksByWorkflowStep should exclude archived tasks - tasksByStep, err := repo.ListTasksByWorkflowStep(ctx, "step-1") - if err != nil { - t.Fatalf("failed to list tasks by step: %v", err) - } - if len(tasksByStep) != 1 { - t.Errorf("expected 1 task by step (excluding archived), got %d", len(tasksByStep)) - } - - // CountTasksByWorkflow should exclude archived tasks - count, err := repo.CountTasksByWorkflow(ctx, "wf-1") - if err != nil { - t.Fatalf("failed to count tasks: %v", err) - } - if count != 1 { - t.Errorf("expected count 1, got %d", count) - } - - // CountTasksByWorkflowStep should exclude archived tasks - countByStep, err := repo.CountTasksByWorkflowStep(ctx, "step-1") - if err != nil { - t.Fatalf("failed to count tasks by step: %v", err) - } - if countByStep != 1 { - t.Errorf("expected count 1, got %d", countByStep) - } -} - -func TestSQLiteRepository_ListTasksByWorkspace_IncludeArchived(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Active Task"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Archived Task"}) - _ = repo.ArchiveTask(ctx, "task-2") - - // Without includeArchived: should return only active task - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10, false) - if err != nil { - t.Fatalf("failed to list tasks: %v", err) - } - if total != 1 { - t.Errorf("expected 1 total without archived, got %d", total) - } - if len(tasks) != 1 || tasks[0].ID != "task-1" { - t.Errorf("expected only task-1, got %v", tasks) - } - - // With includeArchived: should return both tasks - tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10, true) - if err != nil { - t.Fatalf("failed to list tasks with archived: %v", err) - } - if total != 2 { - t.Errorf("expected 2 total with archived, got %d", total) - } - if len(tasks) != 2 { - t.Errorf("expected 2 tasks, got %d", len(tasks)) - } - - // Search with includeArchived=false should filter archived - _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "Task", 1, 10, false) - if err != nil { - t.Fatalf("failed to search tasks: %v", err) - } - if total != 1 { - t.Errorf("expected 1 search result without archived, got %d", total) - } - - // Search with includeArchived=true should include archived - _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "Task", 1, 10, true) - if err != nil { - t.Fatalf("failed to search tasks with archived: %v", err) - } - if total != 2 { - t.Errorf("expected 2 search results with archived, got %d", total) - } -} - -func TestSQLiteRepository_ListTasksForAutoArchive(t *testing.T) { - repo, cleanup := createTestSQLiteRepo(t) - defer cleanup() - ctx := context.Background() - - // Create the workflow_steps table (normally created by workflow repo) - _, err := repo.DB().Exec(` - CREATE TABLE IF NOT EXISTS workflow_steps ( - id TEXT PRIMARY KEY, - workflow_id TEXT NOT NULL, - name TEXT NOT NULL, - position INTEGER NOT NULL, - color TEXT, - prompt TEXT, - events TEXT, - allow_manual_move INTEGER DEFAULT 1, - is_start_step INTEGER DEFAULT 0, - auto_archive_after_hours INTEGER DEFAULT 0, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL - ) - `) - if err != nil { - t.Fatalf("failed to create workflow_steps table: %v", err) - } - - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} - _ = repo.CreateWorkflow(ctx, workflow) - - now := time.Now().UTC() - - // Create workflow step with auto-archive after 1 hour - _, err = repo.DB().Exec(`INSERT INTO workflow_steps (id, workflow_id, name, position, auto_archive_after_hours, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, "step-done", "wf-1", "Done", 2, 1, now, now) - if err != nil { - t.Fatalf("failed to create workflow step: %v", err) - } - - // Create workflow step without auto-archive - _, err = repo.DB().Exec(`INSERT INTO workflow_steps (id, workflow_id, name, position, auto_archive_after_hours, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, "step-todo", "wf-1", "Todo", 1, 0, now, now) - if err != nil { - t.Fatalf("failed to create workflow step: %v", err) - } - - oldTime := now.Add(-2 * time.Hour) - - // Task in auto-archive step, updated 2 hours ago — should be eligible - _ = repo.CreateTask(ctx, &models.Task{ - ID: "task-old", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Old Task", - }) - // Backdate updated_at (CreateTask sets it to now) - _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-old") - - // Task in auto-archive step, updated just now — should NOT be eligible - _ = repo.CreateTask(ctx, &models.Task{ - ID: "task-recent", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Recent Task", - }) - - // Task in non-auto-archive step — should NOT be eligible - _ = repo.CreateTask(ctx, &models.Task{ - ID: "task-todo", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-todo", Title: "Todo Task", - }) - _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-todo") - - // Already archived task — should NOT be eligible - _ = repo.CreateTask(ctx, &models.Task{ - ID: "task-archived", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Already Archived", - }) - _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-archived") - _ = repo.ArchiveTask(ctx, "task-archived") - - // List candidates - candidates, err := repo.ListTasksForAutoArchive(ctx) - if err != nil { - t.Fatalf("failed to list auto-archive candidates: %v", err) - } - if len(candidates) != 1 { - t.Fatalf("expected 1 candidate, got %d", len(candidates)) - } - if candidates[0].ID != "task-old" { - t.Errorf("expected task-old, got %s", candidates[0].ID) - } -} diff --git a/apps/backend/internal/task/repository/task_repository_test.go b/apps/backend/internal/task/repository/task_repository_test.go new file mode 100644 index 0000000000..e327b1c90c --- /dev/null +++ b/apps/backend/internal/task/repository/task_repository_test.go @@ -0,0 +1,359 @@ +package repository + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/sqlite" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Task CRUD tests + +func TestSQLiteRepository_TaskCRUD(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workspace and workflow for foreign keys + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + // Create task (workflow steps are managed by workflow repository) + task := &models.Task{ + WorkspaceID: "ws-1", + WorkflowID: "wf-123", + WorkflowStepID: "step-123", + Title: "Test Task", + Description: "A test task", + State: v1.TaskStateTODO, + Priority: 5, + Metadata: map[string]interface{}{"key": "value"}, + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + if task.ID == "" { + t.Error("expected task ID to be set") + } + + // Get + retrieved, err := repo.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("failed to get task: %v", err) + } + if retrieved.Title != "Test Task" { + t.Errorf("expected title 'Test Task', got %s", retrieved.Title) + } + if retrieved.Metadata["key"] != "value" { + t.Errorf("expected metadata key 'value', got %v", retrieved.Metadata["key"]) + } + + // Update + task.Title = "Updated Task" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("failed to update task: %v", err) + } + retrieved, _ = repo.GetTask(ctx, task.ID) + if retrieved.Title != "Updated Task" { + t.Errorf("expected title 'Updated Task', got %s", retrieved.Title) + } + + // Delete + if err := repo.DeleteTask(ctx, task.ID); err != nil { + t.Fatalf("failed to delete task: %v", err) + } + _, err = repo.GetTask(ctx, task.ID) + if err == nil { + t.Error("expected task to be deleted") + } +} + +func TestSQLiteRepository_TaskNotFound(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _, err := repo.GetTask(ctx, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent task") + } + + err = repo.UpdateTask(ctx, &models.Task{ID: "nonexistent", Title: "Test"}) + if err == nil { + t.Error("expected error for updating nonexistent task") + } + + err = repo.DeleteTask(ctx, "nonexistent") + if err == nil { + t.Error("expected error for deleting nonexistent task") + } +} + +func TestSQLiteRepository_UpdateTaskState(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workspace, workflow, and task + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO} + _ = repo.CreateTask(ctx, task) + + err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress) + if err != nil { + t.Fatalf("failed to update task state: %v", err) + } + + retrieved, _ := repo.GetTask(ctx, "task-123") + if retrieved.State != v1.TaskStateInProgress { + t.Errorf("expected state IN_PROGRESS, got %s", retrieved.State) + } +} + +func TestSQLiteRepository_UpdateTaskStateNotFound(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + err := repo.UpdateTaskState(ctx, "nonexistent", v1.TaskStateInProgress) + if err == nil { + t.Error("expected error for nonexistent task") + } +} + +func TestSQLiteRepository_ListTasks(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workspace and workflow + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2"}) + + tasks, err := repo.ListTasks(ctx, "wf-123") + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if len(tasks) != 2 { + t.Errorf("expected 2 tasks, got %d", len(tasks)) + } +} + +func TestSQLiteRepository_ListTasksByWorkflowStep(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workspace and workflow + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + // Tasks with different workflow steps + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task 1"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task 2"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-2", Title: "Task 3"}) + + tasks, err := repo.ListTasksByWorkflowStep(ctx, "step-1") + if err != nil { + t.Fatalf("failed to list tasks by workflow step: %v", err) + } + if len(tasks) != 2 { + t.Errorf("expected 2 tasks for step-1, got %d", len(tasks)) + } +} + +func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workspaces and workflow + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + // Create tasks in workspace 1 + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task One"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task Two"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task Three"}) + // Create task in workspace 2 + workflow2 := &models.Workflow{ID: "wf-456", WorkspaceID: "ws-2", Name: "Test Workflow 2"} + _ = repo.CreateWorkflow(ctx, workflow2) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-4", WorkspaceID: "ws-2", WorkflowID: "wf-456", WorkflowStepID: "step-2", Title: "Task Four"}) + + // Test basic listing without search + tasks, total, err := repo.ListTasksByWorkspace(ctx,"ws-1", "", 1, 10, false) + if err != nil { + t.Fatalf("failed to list tasks by workspace: %v", err) + } + if total != 3 { + t.Errorf("expected total 3 tasks for ws-1, got %d", total) + } + if len(tasks) != 3 { + t.Errorf("expected 3 tasks returned, got %d", len(tasks)) + } + + // Test pagination + tasks, total, err = repo.ListTasksByWorkspace(ctx,"ws-1", "", 1, 2, false) + if err != nil { + t.Fatalf("failed to list tasks with pagination: %v", err) + } + if total != 3 { + t.Errorf("expected total 3 tasks, got %d", total) + } + if len(tasks) != 2 { + t.Errorf("expected 2 tasks per page, got %d", len(tasks)) + } + + // Test page 2 + tasksPage2, _, err := repo.ListTasksByWorkspace(ctx,"ws-1", "", 2, 2, false) + if err != nil { + t.Fatalf("failed to list tasks page 2: %v", err) + } + if len(tasksPage2) != 1 { + t.Errorf("expected 1 task on page 2, got %d", len(tasksPage2)) + } +} + +func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workspace, workflow, and repository + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + repository := &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "MyProject", LocalPath: "/home/user/projects/myproject"} + _ = repo.CreateRepository(ctx, repository) + + // Create tasks with different titles and descriptions + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Fix authentication bug", Description: "Users cannot login"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Add new feature", Description: "Implement dark mode"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Refactor codebase", Description: "Clean up authentication module"}) + + // Link task-1 to the repository + _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-1", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main"}) + + // Test search by title + _, totalAuth, err := repo.ListTasksByWorkspace(ctx,"ws-1", "authentication", 1, 10, false) + if err != nil { + t.Fatalf("failed to search tasks by title: %v", err) + } + if totalAuth != 2 { + t.Errorf("expected 2 tasks matching 'authentication', got %d", totalAuth) + } + + // Test search by description + tasksDarkMode, totalDarkMode, err := repo.ListTasksByWorkspace(ctx,"ws-1", "dark mode", 1, 10, false) + if err != nil { + t.Fatalf("failed to search tasks by description: %v", err) + } + if totalDarkMode != 1 { + t.Errorf("expected 1 task matching 'dark mode', got %d", totalDarkMode) + } + if len(tasksDarkMode) != 1 || tasksDarkMode[0].ID != "task-2" { + t.Errorf("expected task-2 to be returned") + } + + // Test search by repository name + tasksRepo, totalRepo, err := repo.ListTasksByWorkspace(ctx,"ws-1", "MyProject", 1, 10, false) + if err != nil { + t.Fatalf("failed to search tasks by repository name: %v", err) + } + if totalRepo != 1 { + t.Errorf("expected 1 task matching repository 'MyProject', got %d", totalRepo) + } + if len(tasksRepo) != 1 || tasksRepo[0].ID != "task-1" { + t.Errorf("expected task-1 to be returned") + } + + // Test search by repository local_path + _, totalPath, err := repo.ListTasksByWorkspace(ctx,"ws-1", "myproject", 1, 10, false) + if err != nil { + t.Fatalf("failed to search tasks by repository path: %v", err) + } + if totalPath != 1 { + t.Errorf("expected 1 task matching repository path 'myproject', got %d", totalPath) + } + + // Test search with no results + tasksNone, totalNone, err := repo.ListTasksByWorkspace(ctx,"ws-1", "nonexistent", 1, 10, false) + if err != nil { + t.Fatalf("failed to search tasks with no results: %v", err) + } + if totalNone != 0 { + t.Errorf("expected 0 tasks matching 'nonexistent', got %d", totalNone) + } + if len(tasksNone) != 0 { + t.Errorf("expected empty tasks slice, got %d tasks", len(tasksNone)) + } +} + +func TestSQLiteRepository_Persistence(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "persistence_test.db") + ctx := context.Background() + + // Create repository and add data + dbConn1, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("failed to open SQLite database: %v", err) + } + sqlxDB1 := sqlx.NewDb(dbConn1, "sqlite3") + repo1, err := sqlite.NewWithDB(sqlxDB1) + if err != nil { + t.Fatalf("failed to create first repository: %v", err) + } + + _ = repo1.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + workflow := &models.Workflow{ID: "persist-wf", WorkspaceID: "ws-1", Name: "Persistent Workflow"} + _ = repo1.CreateWorkflow(ctx, workflow) + if err := repo1.Close(); err != nil { + t.Fatalf("failed to close repo: %v", err) + } + if err := sqlxDB1.Close(); err != nil { + t.Fatalf("failed to close sqlite db: %v", err) + } + + // Reopen repository and verify data persisted + dbConn2, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("failed to open SQLite database: %v", err) + } + sqlxDB2 := sqlx.NewDb(dbConn2, "sqlite3") + repo2, err := sqlite.NewWithDB(sqlxDB2) + if err != nil { + t.Fatalf("failed to create second repository: %v", err) + } + defer func() { + if err := sqlxDB2.Close(); err != nil { + t.Errorf("failed to close sqlite db: %v", err) + } + if err := repo2.Close(); err != nil { + t.Errorf("failed to close repo: %v", err) + } + }() + + retrieved, err := repo2.GetWorkflow(ctx, "persist-wf") + if err != nil { + t.Fatalf("failed to get workflow after reopen: %v", err) + } + if retrieved.Name != "Persistent Workflow" { + t.Errorf("expected name 'Persistent Workflow', got %s", retrieved.Name) + } +} diff --git a/apps/backend/internal/task/repository/workflow_repository_test.go b/apps/backend/internal/task/repository/workflow_repository_test.go new file mode 100644 index 0000000000..d608aa070f --- /dev/null +++ b/apps/backend/internal/task/repository/workflow_repository_test.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// Workflow CRUD tests + +func TestSQLiteRepository_WorkflowCRUD(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + workspace := &models.Workspace{ID: "ws-1", Name: "Workspace"} + if err := repo.CreateWorkspace(ctx, workspace); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + + // Create + workflow := &models.Workflow{WorkspaceID: workspace.ID, Name: "Test Workflow", Description: "A test workflow"} + if err := repo.CreateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to create workflow: %v", err) + } + if workflow.ID == "" { + t.Error("expected workflow ID to be set") + } + if workflow.CreatedAt.IsZero() { + t.Error("expected CreatedAt to be set") + } + + // Get + retrieved, err := repo.GetWorkflow(ctx, workflow.ID) + if err != nil { + t.Fatalf("failed to get workflow: %v", err) + } + if retrieved.Name != "Test Workflow" { + t.Errorf("expected name 'Test Workflow', got %s", retrieved.Name) + } + + // Update + workflow.Name = "Updated Name" + if err := repo.UpdateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to update workflow: %v", err) + } + retrieved, _ = repo.GetWorkflow(ctx, workflow.ID) + if retrieved.Name != "Updated Name" { + t.Errorf("expected name 'Updated Name', got %s", retrieved.Name) + } + + // Delete + if err := repo.DeleteWorkflow(ctx, workflow.ID); err != nil { + t.Fatalf("failed to delete workflow: %v", err) + } + _, err = repo.GetWorkflow(ctx, workflow.ID) + if err == nil { + t.Error("expected workflow to be deleted") + } +} + +func TestSQLiteRepository_WorkflowNotFound(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _, err := repo.GetWorkflow(ctx, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent workflow") + } + + err = repo.UpdateWorkflow(ctx, &models.Workflow{ID: "nonexistent", Name: "Test"}) + if err == nil { + t.Error("expected error for updating nonexistent workflow") + } + + err = repo.DeleteWorkflow(ctx, "nonexistent") + if err == nil { + t.Error("expected error for deleting nonexistent workflow") + } +} + +func TestSQLiteRepository_ListWorkflows(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) + + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Workflow 1"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Workflow 2"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-3", WorkspaceID: "ws-2", Name: "Workflow 3"}) + + workflows, err := repo.ListWorkflows(ctx, "ws-1") + if err != nil { + t.Fatalf("failed to list workflows: %v", err) + } + if len(workflows) != 2 { + t.Errorf("expected 2 workflows, got %d", len(workflows)) + } +} diff --git a/apps/backend/internal/task/service/plan_service.go b/apps/backend/internal/task/service/plan_service.go index 8c24af3022..10c7562ca9 100644 --- a/apps/backend/internal/task/service/plan_service.go +++ b/apps/backend/internal/task/service/plan_service.go @@ -19,6 +19,9 @@ var ( ErrContentRequired = errors.New("content is required") ) +// createdByAgent is the default creator for agent-created plans. +const createdByAgent = "agent" + // PlanService provides task plan business logic. type PlanService struct { repo repository.Repository @@ -55,7 +58,7 @@ func (s *PlanService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*m } createdBy := req.CreatedBy if createdBy == "" { - createdBy = "agent" + createdBy = createdByAgent } plan := &models.TaskPlan{ diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index 9c9d763084..4631e572c0 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -44,6 +44,9 @@ type RepositoryPathValidation struct { var ErrPathNotAllowed = errors.New("path is not within an allowed root") +// gitHEAD is the HEAD git ref. +const gitHEAD = "HEAD" + func (s *Service) DiscoverLocalRepositories(ctx context.Context, root string) (RepositoryDiscoveryResult, error) { roots := s.discoveryRoots() if root != "" { @@ -98,13 +101,14 @@ func (s *Service) ValidateLocalRepositoryPath(ctx context.Context, path string) defaultBranch := "" message := "" - if !allowed { + switch { + case !allowed: message = "Path is outside the allowed roots" - } else if !exists { + case !exists: message = "Path does not exist" - } else if !isDir { + case !isDir: message = "Path is not a directory" - } else { + default: var gitErr error defaultBranch, gitErr = readGitDefaultBranch(absPath) isGit = gitErr == nil @@ -210,68 +214,20 @@ func normalizeRoots(roots []string) []string { func scanRootForRepos(ctx context.Context, root string, maxDepth int) ([]LocalRepository, error) { repos := make([]LocalRepository, 0) - libraryRoot := filepath.Join(root, "Library") - cacheRoot := filepath.Join(root, ".cache") + walker := &repoWalker{ + root: root, + maxDepth: maxDepth, + libraryRoot: filepath.Join(root, "Library"), + cacheRoot: filepath.Join(root, ".cache"), + ctx: ctx, + } err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil + repo, walkErr := walker.visit(path, d, err) + if walkErr != nil { + return walkErr } - if ctx.Err() != nil { - return ctx.Err() - } - if path == root { - return nil - } - - rel, err := filepath.Rel(root, path) - if err != nil { - return nil - } - depth := strings.Count(rel, string(os.PathSeparator)) - if d.IsDir() && depth > maxDepth { - return fs.SkipDir - } - - if isWithinRoot(path, libraryRoot) { - if d.IsDir() { - return fs.SkipDir - } - return nil - } - if isWithinRoot(path, cacheRoot) { - if d.IsDir() { - return fs.SkipDir - } - return nil - } - - name := d.Name() - if d.IsDir() && name == "Library" && filepath.Dir(path) == root { - return fs.SkipDir - } - if d.IsDir() && name == ".cache" && filepath.Dir(path) == root { - return fs.SkipDir - } - if d.IsDir() && strings.HasPrefix(name, ".") && name != ".git" { - return fs.SkipDir - } - if d.IsDir() && name == "node_modules" { - return fs.SkipDir - } - if name == ".git" { - repoPath := filepath.Dir(path) - repo := LocalRepository{ - Path: repoPath, - Name: filepath.Base(repoPath), - DefaultBranch: "", - } - if branch, err := readGitDefaultBranch(repoPath); err == nil { - repo.DefaultBranch = branch - } - repos = append(repos, repo) - if d.IsDir() { - return fs.SkipDir - } + if repo != nil { + repos = append(repos, *repo) } return nil }) @@ -284,6 +240,87 @@ func scanRootForRepos(ctx context.Context, root string, maxDepth int) ([]LocalRe return repos, nil } +// repoWalker holds state for the WalkDir callback used in scanRootForRepos. +type repoWalker struct { + root string + maxDepth int + libraryRoot string + cacheRoot string + ctx context.Context +} + +// visit is the WalkDir callback. Returns a non-nil *LocalRepository when a git repo is found. +func (w *repoWalker) visit(path string, d fs.DirEntry, err error) (*LocalRepository, error) { + if err != nil { + return nil, nil //nolint:nilerr // skip entries that cannot be accessed + } + if w.ctx.Err() != nil { + return nil, w.ctx.Err() + } + if path == w.root { + return nil, nil + } + + if skip := w.skipDir(path, d); skip != nil { + return nil, skip + } + + if d.Name() == ".git" { + return w.makeRepo(path, d), nil + } + return nil, nil +} + +// skipDir returns fs.SkipDir when a directory should not be traversed, or nil to continue. +func (w *repoWalker) skipDir(path string, d fs.DirEntry) error { + rel, err := filepath.Rel(w.root, path) + if err != nil { + return nil + } + depth := strings.Count(rel, string(os.PathSeparator)) + if d.IsDir() && depth > w.maxDepth { + return fs.SkipDir + } + if isWithinRoot(path, w.libraryRoot) || isWithinRoot(path, w.cacheRoot) { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + return w.skipByName(path, d) +} + +// skipByName skips well-known directories that should never be scanned. +func (w *repoWalker) skipByName(path string, d fs.DirEntry) error { + if !d.IsDir() { + return nil + } + name := d.Name() + if (name == "Library" || name == ".cache") && filepath.Dir(path) == w.root { + return fs.SkipDir + } + if strings.HasPrefix(name, ".") && name != ".git" { + return fs.SkipDir + } + if name == "node_modules" { + return fs.SkipDir + } + return nil +} + +// makeRepo builds a LocalRepository from a .git entry path. +func (w *repoWalker) makeRepo(path string, d fs.DirEntry) *LocalRepository { + repoPath := filepath.Dir(path) + repo := &LocalRepository{ + Path: repoPath, + Name: filepath.Base(repoPath), + } + if branch, err := readGitDefaultBranch(repoPath); err == nil { + repo.DefaultBranch = branch + } + return repo +} + func isPathAllowed(path string, roots []string) bool { for _, root := range roots { if root == "" { @@ -322,7 +359,7 @@ func readGitDefaultBranch(repoPath string) (string, error) { if err != nil { return "", err } - headPath := filepath.Join(gitDir, "HEAD") + headPath := filepath.Join(gitDir, gitHEAD) content, err := os.ReadFile(headPath) if err != nil { return "", err @@ -336,7 +373,7 @@ func readGitDefaultBranch(repoPath string) (string, error) { } } if trimmed != "" { - return "HEAD", nil + return gitHEAD, nil } return "", fmt.Errorf("unable to determine branch") } @@ -389,110 +426,9 @@ func listGitBranches(repoPath string) ([]Branch, error) { refsRoot := resolveCommonGitDir(gitDir) branchMap := make(map[string]Branch) - // List local branches from refs/heads - localRefsRoot := filepath.Join(refsRoot, "refs", "heads") - _ = filepath.WalkDir(localRefsRoot, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - return nil - } - rel, err := filepath.Rel(localRefsRoot, path) - if err != nil { - return nil - } - if rel == "" || rel == "." { - return nil - } - name := filepath.ToSlash(rel) - branchMap[name] = Branch{ - Name: name, - Type: "local", - } - return nil - }) - - // List remote branches from refs/remotes - remoteRefsRoot := filepath.Join(refsRoot, "refs", "remotes") - _ = filepath.WalkDir(remoteRefsRoot, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - return nil - } - rel, err := filepath.Rel(remoteRefsRoot, path) - if err != nil { - return nil - } - if rel == "" || rel == "." { - return nil - } - fullPath := filepath.ToSlash(rel) - parts := strings.SplitN(fullPath, "/", 2) - if len(parts) < 2 { - return nil - } - remoteName := parts[0] - branchName := parts[1] - // Skip HEAD references - if branchName == "HEAD" { - return nil - } - key := "remotes/" + fullPath - branchMap[key] = Branch{ - Name: branchName, - Type: "remote", - Remote: remoteName, - } - return nil - }) - - // Read packed-refs file for additional branches - packedRefs := filepath.Join(refsRoot, "packed-refs") - if content, err := os.ReadFile(packedRefs); err == nil { - lines := strings.Split(string(content), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "^") { - continue - } - parts := strings.Split(line, " ") - if len(parts) < 2 { - continue - } - ref := parts[1] - if strings.HasPrefix(ref, "refs/heads/") { - name := strings.TrimPrefix(ref, "refs/heads/") - if _, exists := branchMap[name]; !exists { - branchMap[name] = Branch{ - Name: name, - Type: "local", - } - } - } else if strings.HasPrefix(ref, "refs/remotes/") { - fullPath := strings.TrimPrefix(ref, "refs/remotes/") - remoteParts := strings.SplitN(fullPath, "/", 2) - if len(remoteParts) < 2 { - continue - } - remoteName := remoteParts[0] - branchName := remoteParts[1] - if branchName == "HEAD" { - continue - } - key := "remotes/" + fullPath - if _, exists := branchMap[key]; !exists { - branchMap[key] = Branch{ - Name: branchName, - Type: "remote", - Remote: remoteName, - } - } - } - } - } + collectLocalBranches(filepath.Join(refsRoot, "refs", "heads"), branchMap) + collectRemoteBranches(filepath.Join(refsRoot, "refs", "remotes"), branchMap) + parsePackedRefs(refsRoot, branchMap) if len(branchMap) == 0 { return nil, fmt.Errorf("no branches found") @@ -502,8 +438,6 @@ func listGitBranches(repoPath string) ([]Branch, error) { for _, branch := range branchMap { result = append(result, branch) } - - // Sort: local branches first, then remote branches, alphabetically within each group sort.Slice(result, func(i, j int) bool { if result[i].Type != result[j].Type { return result[i].Type == "local" @@ -513,6 +447,73 @@ func listGitBranches(repoPath string) ([]Branch, error) { } return result[i].Name < result[j].Name }) - return result, nil } + +func collectLocalBranches(localRefsRoot string, branchMap map[string]Branch) { + _ = filepath.WalkDir(localRefsRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, err := filepath.Rel(localRefsRoot, path) + if err != nil || rel == "" || rel == "." { + return nil + } + name := filepath.ToSlash(rel) + branchMap[name] = Branch{Name: name, Type: "local"} + return nil + }) +} + +func collectRemoteBranches(remoteRefsRoot string, branchMap map[string]Branch) { + _ = filepath.WalkDir(remoteRefsRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, err := filepath.Rel(remoteRefsRoot, path) + if err != nil || rel == "" || rel == "." { + return nil + } + fullPath := filepath.ToSlash(rel) + parts := strings.SplitN(fullPath, "/", 2) + if len(parts) < 2 || parts[1] == gitHEAD { + return nil + } + branchMap["remotes/"+fullPath] = Branch{Name: parts[1], Type: "remote", Remote: parts[0]} + return nil + }) +} + +func parsePackedRefs(refsRoot string, branchMap map[string]Branch) { + content, err := os.ReadFile(filepath.Join(refsRoot, "packed-refs")) + if err != nil { + return + } + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "^") { + continue + } + parts := strings.Split(line, " ") + if len(parts) < 2 { + continue + } + ref := parts[1] + if strings.HasPrefix(ref, "refs/heads/") { + name := strings.TrimPrefix(ref, "refs/heads/") + if _, exists := branchMap[name]; !exists { + branchMap[name] = Branch{Name: name, Type: "local"} + } + } else if strings.HasPrefix(ref, "refs/remotes/") { + fullPath := strings.TrimPrefix(ref, "refs/remotes/") + rp := strings.SplitN(fullPath, "/", 2) + if len(rp) < 2 || rp[1] == gitHEAD { + continue + } + key := "remotes/" + fullPath + if _, exists := branchMap[key]; !exists { + branchMap[key] = Branch{Name: rp[1], Type: "remote", Remote: rp[0]} + } + } + } +} diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index 549025f6a8..a58923ccc4 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -189,7 +189,7 @@ func TestResolveGitDir(t *testing.T) { t.Fatalf("mkdir alt git: %v", err) } gitRef := filepath.Join(altRepo, ".git") - relPath := filepath.Join("gitdir") + relPath := "gitdir" if err := os.WriteFile(gitRef, []byte("gitdir: "+relPath+"\n"), 0o644); err != nil { t.Fatalf("write gitdir ref: %v", err) } @@ -284,7 +284,7 @@ func newDiscoveryService(t *testing.T, root string) *Service { if err != nil { t.Fatalf("failed to create test repository: %v", err) } - repo := repository.Repository(repoImpl) + repo := repoImpl t.Cleanup(func() { if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index fc4b8944c5..485d28a4fe 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -2,28 +2,16 @@ package service import ( "context" - "database/sql" "encoding/json" "errors" "fmt" - "path/filepath" "strings" - "time" - "github.com/google/uuid" - v1 "github.com/kandev/kandev/pkg/api/v1" - "go.uber.org/zap" - - "github.com/kandev/kandev/internal/agent/lifecycle" - "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" - "github.com/kandev/kandev/internal/sysprompt" - "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" - wfmodels "github.com/kandev/kandev/internal/workflow/models" "github.com/kandev/kandev/internal/worktree" + wfmodels "github.com/kandev/kandev/internal/workflow/models" ) // WorktreeCleanup provides worktree cleanup on task deletion. @@ -142,2824 +130,3 @@ func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) { func (s *Service) SetStartStepResolver(resolver StartStepResolver) { s.startStepResolver = resolver } - -// Request types - -// TaskRepositoryInput for creating/updating task repositories -type TaskRepositoryInput struct { - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - LocalPath string `json:"local_path,omitempty"` - Name string `json:"name,omitempty"` - DefaultBranch string `json:"default_branch,omitempty"` -} - -// CreateTaskRequest contains the data for creating a new task -type CreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Title string `json:"title"` - Description string `json:"description"` - Priority int `json:"priority"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []TaskRepositoryInput `json:"repositories,omitempty"` - Position int `json:"position"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -// UpdateTaskRequest contains the data for updating a task -type UpdateTaskRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Priority *int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []TaskRepositoryInput `json:"repositories,omitempty"` - Position *int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -// CreateWorkflowRequest contains the data for creating a new workflow -type CreateWorkflowRequest struct { - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - Description string `json:"description"` - WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` -} - -// UpdateWorkflowRequest contains the data for updating a workflow -type UpdateWorkflowRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` -} - -// CreateWorkspaceRequest contains the data for creating a new workspace -type CreateWorkspaceRequest struct { - Name string `json:"name"` - Description string `json:"description"` - OwnerID string `json:"owner_id"` - DefaultExecutorID *string `json:"default_executor_id,omitempty"` - DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` - DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` -} - -// UpdateWorkspaceRequest contains the data for updating a workspace -type UpdateWorkspaceRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - DefaultExecutorID *string `json:"default_executor_id,omitempty"` - DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` - DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` -} - -// CreateRepositoryRequest contains the data for creating a new repository -type CreateRepositoryRequest struct { - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - SourceType string `json:"source_type"` - LocalPath string `json:"local_path"` - Provider string `json:"provider"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - WorktreeBranchPrefix string `json:"worktree_branch_prefix"` - PullBeforeWorktree *bool `json:"pull_before_worktree"` - SetupScript string `json:"setup_script"` - CleanupScript string `json:"cleanup_script"` - DevScript string `json:"dev_script"` -} - -// UpdateRepositoryRequest contains the data for updating a repository -type UpdateRepositoryRequest struct { - Name *string `json:"name,omitempty"` - SourceType *string `json:"source_type,omitempty"` - LocalPath *string `json:"local_path,omitempty"` - Provider *string `json:"provider,omitempty"` - ProviderRepoID *string `json:"provider_repo_id,omitempty"` - ProviderOwner *string `json:"provider_owner,omitempty"` - ProviderName *string `json:"provider_name,omitempty"` - DefaultBranch *string `json:"default_branch,omitempty"` - WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` - PullBeforeWorktree *bool `json:"pull_before_worktree,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 -type CreateExecutorRequest struct { - Name string `json:"name"` - Type models.ExecutorType `json:"type"` - Status models.ExecutorStatus `json:"status"` - IsSystem bool `json:"is_system"` - Resumable bool `json:"resumable"` - Config map[string]string `json:"config,omitempty"` -} - -// UpdateExecutorRequest contains the data for updating an executor -type UpdateExecutorRequest struct { - Name *string `json:"name,omitempty"` - Type *models.ExecutorType `json:"type,omitempty"` - Status *models.ExecutorStatus `json:"status,omitempty"` - Resumable *bool `json:"resumable,omitempty"` - Config map[string]string `json:"config,omitempty"` -} - -// CreateEnvironmentRequest contains the data for creating an environment -type CreateEnvironmentRequest struct { - Name string `json:"name"` - Kind models.EnvironmentKind `json:"kind"` - WorktreeRoot string `json:"worktree_root,omitempty"` - ImageTag string `json:"image_tag,omitempty"` - Dockerfile string `json:"dockerfile,omitempty"` - BuildConfig map[string]string `json:"build_config,omitempty"` -} - -// UpdateEnvironmentRequest contains the data for updating an environment -type UpdateEnvironmentRequest struct { - Name *string `json:"name,omitempty"` - Kind *models.EnvironmentKind `json:"kind,omitempty"` - WorktreeRoot *string `json:"worktree_root,omitempty"` - ImageTag *string `json:"image_tag,omitempty"` - Dockerfile *string `json:"dockerfile,omitempty"` - BuildConfig map[string]string `json:"build_config,omitempty"` -} - -type ListMessagesRequest struct { - TaskSessionID string - Limit int - Before string - After string - Sort string -} - -// CreateRepositoryScriptRequest contains the data for creating a repository script -type CreateRepositoryScriptRequest struct { - RepositoryID string `json:"repository_id"` - Name string `json:"name"` - Command string `json:"command"` - Position int `json:"position"` -} - -// UpdateRepositoryScriptRequest contains the data for updating a repository script -type UpdateRepositoryScriptRequest struct { - Name *string `json:"name,omitempty"` - Command *string `json:"command,omitempty"` - Position *int `json:"position,omitempty"` -} - -// Task operations - -// CreateTask creates a new task and publishes a task.created event -func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) { - // Auto-resolve start step if not provided - workflowStepID := req.WorkflowStepID - if workflowStepID == "" && req.WorkflowID != "" && s.startStepResolver != nil { - resolvedID, err := s.startStepResolver.ResolveStartStep(ctx, req.WorkflowID) - if err != nil { - s.logger.Warn("failed to resolve start step, using empty", - zap.String("workflow_id", req.WorkflowID), - zap.Error(err)) - } else { - workflowStepID = resolvedID - } - } - - state := v1.TaskStateCreated - if req.State != nil { - state = *req.State - } - task := &models.Task{ - ID: uuid.New().String(), - WorkspaceID: req.WorkspaceID, - WorkflowID: req.WorkflowID, - WorkflowStepID: workflowStepID, - Title: req.Title, - Description: req.Description, - State: state, - Priority: req.Priority, - Position: req.Position, - Metadata: req.Metadata, - } - - if err := s.repo.CreateTask(ctx, task); err != nil { - s.logger.Error("failed to create task", zap.Error(err)) - return nil, err - } - - // Create task-repository associations - var repoByPath map[string]*models.Repository - for _, repoInput := range req.Repositories { - if repoInput.RepositoryID == "" && repoInput.LocalPath != "" { - repos, err := s.repo.ListRepositories(ctx, req.WorkspaceID) - if err != nil { - s.logger.Error("failed to list repositories", zap.Error(err)) - return nil, err - } - repoByPath = make(map[string]*models.Repository, len(repos)) - for _, repo := range repos { - if repo.LocalPath == "" { - continue - } - repoByPath[repo.LocalPath] = repo - } - break - } - } - - for i, repoInput := range req.Repositories { - repositoryID := repoInput.RepositoryID - baseBranch := repoInput.BaseBranch - if repositoryID == "" && repoInput.LocalPath != "" { - repo := repoByPath[repoInput.LocalPath] - if repo == nil { - name := strings.TrimSpace(repoInput.Name) - if name == "" { - name = filepath.Base(repoInput.LocalPath) - } - defaultBranch := repoInput.DefaultBranch - if defaultBranch == "" { - defaultBranch = repoInput.BaseBranch - } - created, err := s.CreateRepository(ctx, &CreateRepositoryRequest{ - WorkspaceID: req.WorkspaceID, - Name: name, - SourceType: "local", - LocalPath: repoInput.LocalPath, - DefaultBranch: defaultBranch, - }) - if err != nil { - return nil, err - } - repo = created - repoByPath[repoInput.LocalPath] = repo - } - repositoryID = repo.ID - if baseBranch == "" { - baseBranch = repo.DefaultBranch - } - } - if repositoryID == "" { - return nil, fmt.Errorf("repository_id is required") - } - taskRepo := &models.TaskRepository{ - TaskID: task.ID, - RepositoryID: repositoryID, - BaseBranch: baseBranch, - Position: i, - Metadata: make(map[string]interface{}), - } - if err := s.repo.CreateTaskRepository(ctx, taskRepo); err != nil { - s.logger.Error("failed to create task repository", zap.Error(err)) - return nil, err - } - } - - // Load repositories into task for response - repos, err := s.repo.ListTaskRepositories(ctx, task.ID) - if err != nil { - s.logger.Error("failed to list task repositories", zap.Error(err)) - } else { - task.Repositories = repos - } - - s.publishTaskEvent(ctx, events.TaskCreated, task, nil) - s.logger.Info("task created", zap.String("task_id", task.ID), zap.String("title", task.Title)) - - return task, nil -} - -// GetTask retrieves a task by ID and populates repositories -func (s *Service) GetTask(ctx context.Context, id string) (*models.Task, error) { - task, err := s.repo.GetTask(ctx, id) - if err != nil { - return nil, err - } - - // Load task repositories - repos, err := s.repo.ListTaskRepositories(ctx, id) - if err != nil { - s.logger.Error("failed to list task repositories", zap.Error(err)) - } else { - task.Repositories = repos - } - - return task, nil -} - -// UpdateTask updates an existing task and publishes a task.updated event -func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequest) (*models.Task, error) { - task, err := s.repo.GetTask(ctx, id) - if err != nil { - return nil, err - } - var oldState *v1.TaskState - stateChanged := false - - if req.Title != nil { - task.Title = *req.Title - } - if req.Description != nil { - task.Description = *req.Description - } - if req.Priority != nil { - task.Priority = *req.Priority - } - if req.State != nil && task.State != *req.State { - current := task.State - oldState = ¤t - task.State = *req.State - stateChanged = true - } - if req.Position != nil { - task.Position = *req.Position - } - if req.Metadata != nil { - task.Metadata = req.Metadata - } - task.UpdatedAt = time.Now().UTC() - - if err := s.repo.UpdateTask(ctx, task); err != nil { - s.logger.Error("failed to update task", zap.String("task_id", id), zap.Error(err)) - return nil, err - } - - // Update task repositories if provided - if req.Repositories != nil { - // Delete existing repositories - if err := s.repo.DeleteTaskRepositoriesByTask(ctx, id); err != nil { - s.logger.Error("failed to delete task repositories", zap.Error(err)) - return nil, err - } - - // Resolve local paths to repository IDs (same logic as CreateTask) - var repoByPath map[string]*models.Repository - for _, repoInput := range req.Repositories { - if repoInput.RepositoryID == "" && repoInput.LocalPath != "" { - repos, err := s.repo.ListRepositories(ctx, task.WorkspaceID) - if err != nil { - s.logger.Error("failed to list repositories", zap.Error(err)) - return nil, err - } - repoByPath = make(map[string]*models.Repository, len(repos)) - for _, repo := range repos { - if repo.LocalPath == "" { - continue - } - repoByPath[repo.LocalPath] = repo - } - break - } - } - - // Create new repositories - for i, repoInput := range req.Repositories { - repositoryID := repoInput.RepositoryID - baseBranch := repoInput.BaseBranch - if repositoryID == "" && repoInput.LocalPath != "" { - repo := repoByPath[repoInput.LocalPath] - if repo == nil { - name := strings.TrimSpace(repoInput.Name) - if name == "" { - name = filepath.Base(repoInput.LocalPath) - } - defaultBranch := repoInput.DefaultBranch - if defaultBranch == "" { - defaultBranch = repoInput.BaseBranch - } - created, err := s.CreateRepository(ctx, &CreateRepositoryRequest{ - WorkspaceID: task.WorkspaceID, - Name: name, - SourceType: "local", - LocalPath: repoInput.LocalPath, - DefaultBranch: defaultBranch, - }) - if err != nil { - return nil, err - } - repo = created - if repoByPath == nil { - repoByPath = make(map[string]*models.Repository) - } - repoByPath[repoInput.LocalPath] = repo - } - repositoryID = repo.ID - if baseBranch == "" { - baseBranch = repo.DefaultBranch - } - } - taskRepo := &models.TaskRepository{ - TaskID: task.ID, - RepositoryID: repositoryID, - BaseBranch: baseBranch, - Position: i, - Metadata: make(map[string]interface{}), - } - if err := s.repo.CreateTaskRepository(ctx, taskRepo); err != nil { - s.logger.Error("failed to create task repository", zap.Error(err)) - return nil, err - } - } - } - - // Load repositories into task for response - repos, err := s.repo.ListTaskRepositories(ctx, task.ID) - if err != nil { - s.logger.Error("failed to list task repositories", zap.Error(err)) - } else { - task.Repositories = repos - } - - if stateChanged && oldState != nil { - s.publishTaskEvent(ctx, events.TaskStateChanged, task, oldState) - } - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) - s.logger.Info("task updated", zap.String("task_id", task.ID)) - - return task, nil -} - -// ArchiveTask archives a task by setting its archived_at timestamp. -// The task remains in the DB but is excluded from active board views. -// Active agent sessions are stopped and worktrees cleaned up in background. -func (s *Service) ArchiveTask(ctx context.Context, id string) error { - start := time.Now() - - // 1. Get task and verify it exists - task, err := s.repo.GetTask(ctx, id) - if err != nil { - return err - } - - if task.ArchivedAt != nil { - return fmt.Errorf("task is already archived: %s", id) - } - - // 2. Gather data needed for cleanup BEFORE archive - var activeSessionIDs []string - if s.executionStopper != nil { - activeSessions, err := s.repo.ListActiveTaskSessionsByTaskID(ctx, id) - if err != nil { - s.logger.Warn("failed to list active sessions for archive", - zap.String("task_id", id), - zap.Error(err)) - } - for _, sess := range activeSessions { - activeSessionIDs = append(activeSessionIDs, sess.ID) - } - } - - sessions, err := s.repo.ListTaskSessions(ctx, id) - if err != nil { - s.logger.Warn("failed to list task sessions for archive", - zap.String("task_id", id), - zap.Error(err)) - } - - var worktrees []*worktree.Worktree - if s.worktreeCleanup != nil { - if provider, ok := s.worktreeCleanup.(WorktreeProvider); ok { - worktrees, err = provider.GetAllByTaskID(ctx, id) - if err != nil { - s.logger.Warn("failed to list worktrees for archive", - zap.String("task_id", id), - zap.Error(err)) - } - } - } - - // 3. Set archived_at in DB - if err := s.repo.ArchiveTask(ctx, id); err != nil { - return err - } - - // 4. Re-read task for updated archived_at field - task, err = s.repo.GetTask(ctx, id) - if err != nil { - return err - } - - // 5. Publish task.updated event so frontend removes from board - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) - s.logger.Info("task archived", - zap.String("task_id", id), - zap.Duration("duration", time.Since(start))) - - // 6. Background: Stop agents and cleanup worktrees - if len(activeSessionIDs) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 { - go func() { - cleanupStart := time.Now() - cleanupCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if s.executionStopper != nil && len(activeSessionIDs) > 0 { - for _, sessionID := range activeSessionIDs { - if err := s.executionStopper.StopSession(cleanupCtx, sessionID, "task archived", true); err != nil { - s.logger.Warn("failed to stop session on task archive", - zap.String("task_id", id), - zap.String("session_id", sessionID), - zap.Error(err)) - } - } - } - - cleanupErrors := s.performTaskCleanup(cleanupCtx, id, sessions, worktrees) - - if len(cleanupErrors) > 0 { - s.logger.Warn("task archive cleanup completed with errors", - zap.String("task_id", id), - zap.Int("error_count", len(cleanupErrors)), - zap.Duration("duration", time.Since(cleanupStart))) - } else { - s.logger.Info("task archive cleanup completed", - zap.String("task_id", id), - zap.Duration("duration", time.Since(cleanupStart))) - } - }() - } - - return nil -} - -// DeleteTask deletes a task and publishes a task.deleted event. -// For fast UI response, the DB delete and event publish happen synchronously, -// while agent stopping and worktree cleanup happen asynchronously. -func (s *Service) DeleteTask(ctx context.Context, id string) error { - start := time.Now() - - // 1. Get task (sync, fast) - task, err := s.repo.GetTask(ctx, id) - if err != nil { - return err - } - - // 2. Gather data needed for cleanup BEFORE delete (sync, fast) - 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)) - } - - var worktrees []*worktree.Worktree - if s.worktreeCleanup != nil { - if provider, ok := s.worktreeCleanup.(WorktreeProvider); 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.Error(err)) - } - } 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.Error(err)) - } - } - } - - // 3. Get active session IDs for stopping agents (sync, fast) - // Must query before delete since DB records will be gone - var activeSessionIDs []string - if s.executionStopper != nil { - activeSessions, err := s.repo.ListActiveTaskSessionsByTaskID(ctx, id) - if err != nil { - s.logger.Warn("failed to list active sessions for delete", - zap.String("task_id", id), - zap.Error(err)) - } - for _, sess := range activeSessions { - activeSessionIDs = append(activeSessionIDs, sess.ID) - } - } - - // 4. Delete from DB (sync, fast) - if err := s.repo.DeleteTask(ctx, id); err != nil { - s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err)) - return err - } - - // 5. Publish event (sync, fast) - frontend removes task immediately - s.publishTaskEvent(ctx, events.TaskDeleted, task, nil) - s.logger.Info("task deleted", - zap.String("task_id", id), - zap.Duration("duration", time.Since(start))) - - // 6. Return immediately - all remaining cleanup is async - - // 7. Background: Stop agents and cleanup worktrees - if len(activeSessionIDs) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 { - go func() { - cleanupStart := time.Now() - cleanupCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - // Stop running agents using pre-fetched session IDs - if s.executionStopper != nil && len(activeSessionIDs) > 0 { - for _, sessionID := range activeSessionIDs { - if err := s.executionStopper.StopSession(cleanupCtx, sessionID, "task deleted", true); err != nil { - s.logger.Warn("failed to stop session on task delete", - zap.String("task_id", id), - zap.String("session_id", sessionID), - zap.Error(err)) - } - } - } - - // Cleanup worktrees and other resources - cleanupErrors := s.performTaskCleanup(cleanupCtx, id, sessions, worktrees) - - if len(cleanupErrors) > 0 { - s.logger.Warn("task cleanup completed with errors", - zap.String("task_id", id), - zap.Int("error_count", len(cleanupErrors)), - zap.Duration("duration", time.Since(cleanupStart))) - } else { - s.logger.Info("task cleanup completed", - zap.String("task_id", id), - zap.Duration("duration", time.Since(cleanupStart))) - } - }() - } - - return nil -} - -// performTaskCleanup handles post-deletion cleanup operations. -// Handles worktree cleanup and executor_running records. -// Agent stopping is handled separately in the DeleteTask background goroutine. -// Returns a slice of errors encountered (empty if all succeeded). -func (s *Service) performTaskCleanup( - ctx context.Context, - taskID string, - sessions []*models.TaskSession, - worktrees []*worktree.Worktree, -) []error { - var errs []error - - // Cleanup worktrees - if len(worktrees) > 0 { - if cleaner, ok := s.worktreeCleanup.(WorktreeBatchCleaner); ok { - if err := cleaner.CleanupWorktrees(ctx, worktrees); err != nil { - s.logger.Warn("failed to cleanup worktrees after delete", - zap.String("task_id", taskID), - zap.Error(err)) - errs = append(errs, fmt.Errorf("cleanup worktrees: %w", err)) - } - } - } - - // Delete executor running records for sessions - 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", - zap.String("task_id", taskID), - zap.String("session_id", session.ID), - zap.Error(err)) - // Don't add to errs - this is a debug-level issue - } - } - - return errs -} - -// ListTasks returns all tasks for a workflow -func (s *Service) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { - tasks, err := s.repo.ListTasks(ctx, workflowID) - if err != nil { - return nil, err - } - - // Load repositories for each task - for _, task := range tasks { - repos, err := s.repo.ListTaskRepositories(ctx, task.ID) - if err != nil { - s.logger.Error("failed to list task repositories", zap.String("task_id", task.ID), zap.Error(err)) - } else { - task.Repositories = repos - } - } - - return tasks, nil -} - -// ListTasksByWorkspace returns paginated tasks for a workspace with task repositories loaded. -// If query is non-empty, filters by task title, description, repository name, or repository path. -func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int, includeArchived bool) ([]*models.Task, int, error) { - tasks, total, err := s.repo.ListTasksByWorkspace(ctx, workspaceID, query, page, pageSize, includeArchived) - if err != nil { - return nil, 0, err - } - - // Load repositories for each task - for _, task := range tasks { - repos, err := s.repo.ListTaskRepositories(ctx, task.ID) - if err != nil { - s.logger.Error("failed to list task repositories", zap.String("task_id", task.ID), zap.Error(err)) - } else { - task.Repositories = repos - } - } - - return tasks, total, nil -} - -// ListTaskSessions returns all sessions for a task. -func (s *Service) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { - return s.repo.ListTaskSessions(ctx, taskID) -} - -// GetTaskSession returns a single session by ID. -func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models.TaskSession, error) { - return s.repo.GetTaskSession(ctx, sessionID) -} - -// GetPrimarySession returns the primary session for a task. -func (s *Service) GetPrimarySession(ctx context.Context, taskID string) (*models.TaskSession, error) { - return s.repo.GetPrimarySessionByTaskID(ctx, taskID) -} - -// GetPrimarySessionIDsForTasks returns a map of task ID to primary session ID for the given task IDs. -// Tasks without a primary session are not included in the result. -func (s *Service) GetPrimarySessionIDsForTasks(ctx context.Context, taskIDs []string) (map[string]string, error) { - return s.repo.GetPrimarySessionIDsByTaskIDs(ctx, taskIDs) -} - -// GetSessionCountsForTasks returns a map of task ID to session count for the given task IDs. -func (s *Service) GetSessionCountsForTasks(ctx context.Context, taskIDs []string) (map[string]int, error) { - return s.repo.GetSessionCountsByTaskIDs(ctx, taskIDs) -} - -// GetPrimarySessionInfoForTasks returns a map of task ID to primary session info for the given task IDs. -func (s *Service) GetPrimarySessionInfoForTasks(ctx context.Context, taskIDs []string) (map[string]*models.TaskSession, error) { - return s.repo.GetPrimarySessionInfoByTaskIDs(ctx, taskIDs) -} - -// SetPrimarySession sets a session as the primary session for its task. -// This will unset any existing primary session for the same task. -func (s *Service) SetPrimarySession(ctx context.Context, sessionID string) error { - if err := s.repo.SetSessionPrimary(ctx, sessionID); err != nil { - s.logger.Error("failed to set primary session", - zap.String("session_id", sessionID), - zap.Error(err)) - return err - } - return nil -} - -// MoveSessionToStep moves a session to a different workflow step. -func (s *Service) MoveSessionToStep(ctx context.Context, sessionID string, stepID string) error { - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, stepID); err != nil { - s.logger.Error("failed to move session to step", - zap.String("session_id", sessionID), - zap.String("step_id", stepID), - zap.Error(err)) - return err - } - return nil -} - -// UpdateSessionReviewStatus updates the review status of a session. -func (s *Service) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, status); err != nil { - s.logger.Error("failed to update session review status", - zap.String("session_id", sessionID), - zap.String("status", status), - zap.Error(err)) - return err - } - return nil -} - -// ApproveSessionResult contains the result of approving a session -type ApproveSessionResult struct { - Session *models.TaskSession - Task *models.Task - WorkflowStep *wfmodels.WorkflowStep -} - -// ApproveSession approves a session's current step and moves it to the next step. -// It reads the step's on_turn_complete actions to determine where to transition. -// If no transition actions are configured, it falls back to the next step by position. -func (s *Service) ApproveSession(ctx context.Context, sessionID string) (*ApproveSessionResult, error) { - // Update review status to approved - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, "approved"); err != nil { - return nil, fmt.Errorf("failed to update review status: %w", err) - } - - result := &ApproveSessionResult{} - - // Reload session to get updated review status - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("failed to reload session: %w", err) - } - result.Session = session - - // Get the current workflow step to check for transition targets - if session.WorkflowStepID != nil && s.workflowStepGetter != nil { - step, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID) - if err != nil { - s.logger.Warn("failed to get workflow step for approval transition", - zap.String("workflow_step_id", *session.WorkflowStepID), - zap.Error(err)) - } else { - // Determine the target step from on_turn_complete actions - var newStepID string - for _, action := range step.Events.OnTurnComplete { - switch action.Type { - case "move_to_next": - nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, step.WorkflowID, step.Position) - if err != nil { - s.logger.Warn("failed to get next step by position", - zap.String("workflow_id", step.WorkflowID), - zap.Int("current_position", step.Position), - zap.Error(err)) - } else if nextStep != nil { - newStepID = nextStep.ID - } - case "move_to_step": - if stepID, ok := action.Config["step_id"].(string); ok && stepID != "" { - newStepID = stepID - } - } - if newStepID != "" { - break - } - } - - // Fall back to next step by position if no transition actions found - if newStepID == "" && len(step.Events.OnTurnComplete) == 0 { - nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, step.WorkflowID, step.Position) - if err != nil { - s.logger.Warn("failed to get next step by position for fallback", - zap.String("workflow_id", step.WorkflowID), - zap.Int("current_position", step.Position), - zap.Error(err)) - } else if nextStep != nil { - newStepID = nextStep.ID - s.logger.Info("using next step by position for approval transition (fallback)", - zap.String("current_step", step.Name), - zap.String("next_step", nextStep.Name)) - } - } - - if newStepID != "" { - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, newStepID); err != nil { - s.logger.Error("failed to move session to next step after approval", - zap.String("session_id", sessionID), - zap.String("step_id", newStepID), - zap.Error(err)) - } else { - // Also move the task to the new step - task, err := s.repo.GetTask(ctx, session.TaskID) - if err != nil { - s.logger.Error("failed to get task for approval transition", - zap.String("task_id", session.TaskID), - zap.Error(err)) - } else { - task.WorkflowStepID = newStepID - task.UpdatedAt = time.Now().UTC() - if err := s.repo.UpdateTask(ctx, task); err != nil { - s.logger.Error("failed to move task to next step after approval", - zap.String("task_id", session.TaskID), - zap.String("step_id", newStepID), - zap.Error(err)) - } else { - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) - result.Task = task - } - } - - // Reload session with new step - session, _ = s.repo.GetTaskSession(ctx, sessionID) - result.Session = session - - // Get the new workflow step for the response - newStep, err := s.workflowStepGetter.GetStep(ctx, newStepID) - if err == nil { - result.WorkflowStep = newStep - } - - s.logger.Info("session approved and moved to next step", - zap.String("session_id", sessionID), - zap.String("from_step", step.ID), - zap.String("to_step", newStepID)) - } - } else { - s.logger.Info("session approved but no next step found (may be at final step)", - zap.String("session_id", sessionID), - zap.String("current_step", step.ID), - zap.String("current_step_name", step.Name)) - } - } - } - - return result, nil -} - -// UpdateTaskState updates the state of a task, moves it to the matching column, -// and publishes a task.state_changed event -func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) { - task, err := s.repo.GetTask(ctx, id) - if err != nil { - return nil, err - } - - oldState := task.State - - if err := s.repo.UpdateTaskState(ctx, id, state); err != nil { - s.logger.Error("failed to update task state", zap.String("task_id", id), zap.Error(err)) - return nil, err - } - - // Reload task to get updated state - task, err = s.repo.GetTask(ctx, id) - if err != nil { - return nil, err - } - - s.logger.Info("task state updated", - zap.String("task_id", id), - zap.String("workflow_step_id", task.WorkflowStepID), - zap.String("state", string(task.State))) - - s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState) - s.logger.Info("task state changed", - zap.String("task_id", id), - zap.String("old_state", string(oldState)), - zap.String("new_state", string(state))) - - return task, nil -} - -// UpdateTaskMetadata updates only the metadata of a task (merges with existing) -func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata map[string]interface{}) (*models.Task, error) { - task, err := s.repo.GetTask(ctx, id) - if err != nil { - return nil, err - } - - // Merge metadata (existing keys are preserved, new keys are added/updated) - if task.Metadata == nil { - task.Metadata = make(map[string]interface{}) - } - for k, v := range metadata { - task.Metadata[k] = v - } - task.UpdatedAt = time.Now().UTC() - - if err := s.repo.UpdateTask(ctx, task); err != nil { - s.logger.Error("failed to update task metadata", zap.String("task_id", id), zap.Error(err)) - return nil, err - } - - s.logger.Debug("task metadata updated", zap.String("task_id", id), zap.Any("metadata", metadata)) - return task, nil -} - -// MoveTaskResult contains the result of a MoveTask operation. -type MoveTaskResult struct { - Task *models.Task - WorkflowStep *wfmodels.WorkflowStep -} - -// MoveTask moves a task to a different workflow step and position -func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, workflowStepID string, position int) (*MoveTaskResult, error) { - task, err := s.repo.GetTask(ctx, id) - if err != nil { - return nil, err - } - - // Check if task's primary session is in a step with pending approval - // If so, prevent moving forward - user must use Approve button or send a message - if task.WorkflowStepID != workflowStepID { - primarySession, err := s.repo.GetPrimarySessionByTaskID(ctx, id) - if err == nil && primarySession != nil { - if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" { - return nil, fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes") - } - } - } - - oldState := task.State - task.WorkflowID = workflowID - task.WorkflowStepID = workflowStepID - task.Position = position - task.UpdatedAt = time.Now().UTC() - - if err := s.repo.UpdateTask(ctx, task); err != nil { - s.logger.Error("failed to move task", zap.String("task_id", id), zap.Error(err)) - return nil, err - } - - // Update active session's workflow_step_id to match the new task workflow step - // This ensures workflow transitions work correctly when tasks are manually moved - if task.WorkflowStepID != "" { - activeSession, err := s.repo.GetActiveTaskSessionByTaskID(ctx, id) - if err == nil && activeSession != nil { - // Only update if the session's workflow_step_id doesn't match - if activeSession.WorkflowStepID == nil || *activeSession.WorkflowStepID != task.WorkflowStepID { - if err := s.repo.UpdateSessionWorkflowStep(ctx, activeSession.ID, task.WorkflowStepID); err != nil { - s.logger.Warn("failed to update session workflow step after task move", - zap.String("task_id", id), - zap.String("session_id", activeSession.ID), - zap.String("workflow_step_id", task.WorkflowStepID), - zap.Error(err)) - // Don't fail the operation, just log the warning - } else { - s.logger.Info("updated session workflow step to match moved task", - zap.String("task_id", id), - zap.String("session_id", activeSession.ID), - zap.String("workflow_step_id", task.WorkflowStepID)) - } - } - } - } - - // Publish state_changed event if state changed, otherwise just updated - if oldState != task.State { - s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState) - } else { - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) - } - - s.logger.Info("task moved", - zap.String("task_id", id), - zap.String("workflow_id", workflowID), - zap.String("workflow_step_id", workflowStepID), - zap.Int("position", position)) - - result := &MoveTaskResult{Task: task} - - // Fetch the workflow step info if getter is available - if s.workflowStepGetter != nil { - step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) - if err != nil { - s.logger.Warn("failed to get workflow step for MoveTask response", - zap.String("workflow_step_id", workflowStepID), - zap.Error(err)) - // Don't fail the operation, just log and continue - } else { - result.WorkflowStep = step - } - } - - return result, nil -} - -// CountTasksByWorkflow returns the number of tasks in a workflow -func (s *Service) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { - return s.repo.CountTasksByWorkflow(ctx, workflowID) -} - -// CountTasksByWorkflowStep returns the number of tasks in a workflow step -func (s *Service) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { - return s.repo.CountTasksByWorkflowStep(ctx, stepID) -} - -// BulkMoveTasksResult contains the result of a BulkMoveTasks operation. -type BulkMoveTasksResult struct { - MovedCount int -} - -// BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step. -// If sourceStepID is empty, all tasks in the source workflow are moved. -func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { - // Get the tasks to move - var tasks []*models.Task - var err error - if sourceStepID != "" { - tasks, err = s.repo.ListTasksByWorkflowStep(ctx, sourceStepID) - } else { - tasks, err = s.repo.ListTasks(ctx, sourceWorkflowID) - } - if err != nil { - return nil, fmt.Errorf("failed to list tasks for bulk move: %w", err) - } - - if len(tasks) == 0 { - return &BulkMoveTasksResult{MovedCount: 0}, nil - } - - now := time.Now().UTC() - for i, task := range tasks { - task.WorkflowID = targetWorkflowID - task.WorkflowStepID = targetStepID - task.Position = i - task.UpdatedAt = now - - if err := s.repo.UpdateTask(ctx, task); err != nil { - s.logger.Error("failed to move task in bulk move", - zap.String("task_id", task.ID), - zap.Error(err)) - return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) - } - - // Update active session's workflow_step_id - activeSession, err := s.repo.GetActiveTaskSessionByTaskID(ctx, task.ID) - if err == nil && activeSession != nil { - if activeSession.WorkflowStepID == nil || *activeSession.WorkflowStepID != targetStepID { - if err := s.repo.UpdateSessionWorkflowStep(ctx, activeSession.ID, targetStepID); err != nil { - s.logger.Warn("failed to update session workflow step during bulk move", - zap.String("task_id", task.ID), - zap.String("session_id", activeSession.ID), - zap.Error(err)) - } - } - } - - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) - } - - s.logger.Info("bulk moved tasks", - zap.String("source_workflow_id", sourceWorkflowID), - zap.String("source_step_id", sourceStepID), - zap.String("target_workflow_id", targetWorkflowID), - zap.String("target_step_id", targetStepID), - zap.Int("moved_count", len(tasks))) - - return &BulkMoveTasksResult{MovedCount: len(tasks)}, nil -} - -// Workspace operations - -// CreateWorkspace creates a new workspace -func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceRequest) (*models.Workspace, error) { - workspace := &models.Workspace{ - ID: uuid.New().String(), - Name: req.Name, - Description: req.Description, - OwnerID: req.OwnerID, - DefaultExecutorID: normalizeOptionalID(req.DefaultExecutorID), - DefaultEnvironmentID: normalizeOptionalID(req.DefaultEnvironmentID), - DefaultAgentProfileID: normalizeOptionalID(req.DefaultAgentProfileID), - } - - if err := s.repo.CreateWorkspace(ctx, workspace); err != nil { - s.logger.Error("failed to create workspace", zap.Error(err)) - return nil, err - } - - s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace) - s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name)) - return workspace, nil -} - -// GetWorkspace retrieves a workspace by ID -func (s *Service) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) { - return s.repo.GetWorkspace(ctx, id) -} - -// UpdateWorkspace updates an existing workspace -func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWorkspaceRequest) (*models.Workspace, error) { - workspace, err := s.repo.GetWorkspace(ctx, id) - if err != nil { - return nil, err - } - - if req.Name != nil { - workspace.Name = *req.Name - } - if req.Description != nil { - workspace.Description = *req.Description - } - if req.DefaultExecutorID != nil { - workspace.DefaultExecutorID = normalizeOptionalID(req.DefaultExecutorID) - } - if req.DefaultEnvironmentID != nil { - workspace.DefaultEnvironmentID = normalizeOptionalID(req.DefaultEnvironmentID) - } - if req.DefaultAgentProfileID != nil { - workspace.DefaultAgentProfileID = normalizeOptionalID(req.DefaultAgentProfileID) - } - workspace.UpdatedAt = time.Now().UTC() - - if err := s.repo.UpdateWorkspace(ctx, workspace); err != nil { - s.logger.Error("failed to update workspace", zap.String("workspace_id", id), zap.Error(err)) - return nil, err - } - - s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace) - s.logger.Info("workspace updated", zap.String("workspace_id", workspace.ID)) - return workspace, nil -} - -// DeleteWorkspace deletes a workspace -func (s *Service) DeleteWorkspace(ctx context.Context, id string) error { - workspace, err := s.repo.GetWorkspace(ctx, id) - if err != nil { - return err - } - if err := s.repo.DeleteWorkspace(ctx, id); err != nil { - s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err)) - return err - } - s.publishWorkspaceEvent(ctx, events.WorkspaceDeleted, workspace) - s.logger.Info("workspace deleted", zap.String("workspace_id", id)) - return nil -} - -func normalizeOptionalID(value *string) *string { - if value == nil { - return nil - } - trimmed := strings.TrimSpace(*value) - if trimmed == "" { - return nil - } - return &trimmed -} - -// ListWorkspaces returns all workspaces -func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { - return s.repo.ListWorkspaces(ctx) -} - -// Workflow operations - -// CreateWorkflow creates a new workflow -func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) (*models.Workflow, error) { - workflow := &models.Workflow{ - ID: uuid.New().String(), - WorkspaceID: req.WorkspaceID, - Name: req.Name, - Description: req.Description, - WorkflowTemplateID: req.WorkflowTemplateID, - } - - if err := s.repo.CreateWorkflow(ctx, workflow); err != nil { - s.logger.Error("failed to create workflow", zap.Error(err)) - return nil, err - } - - // Create workflow steps from template if specified - if req.WorkflowTemplateID != nil && *req.WorkflowTemplateID != "" && s.workflowStepCreator != nil { - if err := s.workflowStepCreator.CreateStepsFromTemplate(ctx, workflow.ID, *req.WorkflowTemplateID); err != nil { - s.logger.Error("failed to create workflow steps from template", - zap.String("workflow_id", workflow.ID), - zap.String("template_id", *req.WorkflowTemplateID), - zap.Error(err)) - // Don't fail workflow creation, just log the error - } - } - - s.publishWorkflowEvent(ctx, events.WorkflowCreated, workflow) - s.logger.Info("workflow created", zap.String("workflow_id", workflow.ID), zap.String("name", workflow.Name)) - return workflow, nil -} - -// GetWorkflow retrieves a workflow by ID -func (s *Service) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { - return s.repo.GetWorkflow(ctx, id) -} - -// UpdateWorkflow updates an existing workflow -func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWorkflowRequest) (*models.Workflow, error) { - workflow, err := s.repo.GetWorkflow(ctx, id) - if err != nil { - return nil, err - } - - if req.Name != nil { - workflow.Name = *req.Name - } - if req.Description != nil { - workflow.Description = *req.Description - } - workflow.UpdatedAt = time.Now().UTC() - - if err := s.repo.UpdateWorkflow(ctx, workflow); err != nil { - s.logger.Error("failed to update workflow", zap.String("workflow_id", id), zap.Error(err)) - return nil, err - } - - s.publishWorkflowEvent(ctx, events.WorkflowUpdated, workflow) - s.logger.Info("workflow updated", zap.String("workflow_id", workflow.ID)) - return workflow, nil -} - -// DeleteWorkflow deletes a workflow -func (s *Service) DeleteWorkflow(ctx context.Context, id string) error { - workflow, err := s.repo.GetWorkflow(ctx, id) - if err != nil { - return err - } - if err := s.repo.DeleteWorkflow(ctx, id); err != nil { - s.logger.Error("failed to delete workflow", zap.String("workflow_id", id), zap.Error(err)) - return err - } - - s.publishWorkflowEvent(ctx, events.WorkflowDeleted, workflow) - s.logger.Info("workflow deleted", zap.String("workflow_id", id)) - return nil -} - -// ListWorkflows returns all workflows for a workspace (or all if empty) -func (s *Service) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { - return s.repo.ListWorkflows(ctx, workspaceID) -} - -// Repository operations - -func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryRequest) (*models.Repository, error) { - sourceType := req.SourceType - if sourceType == "" { - sourceType = "local" - } - prefix := strings.TrimSpace(req.WorktreeBranchPrefix) - if err := worktree.ValidateBranchPrefix(prefix); err != nil { - return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) - } - if prefix == "" { - prefix = worktree.DefaultBranchPrefix - } - pullBeforeWorktree := true - if req.PullBeforeWorktree != nil { - pullBeforeWorktree = *req.PullBeforeWorktree - } - repository := &models.Repository{ - ID: uuid.New().String(), - WorkspaceID: req.WorkspaceID, - Name: req.Name, - SourceType: sourceType, - LocalPath: req.LocalPath, - Provider: req.Provider, - ProviderRepoID: req.ProviderRepoID, - ProviderOwner: req.ProviderOwner, - ProviderName: req.ProviderName, - DefaultBranch: req.DefaultBranch, - WorktreeBranchPrefix: prefix, - PullBeforeWorktree: pullBeforeWorktree, - SetupScript: req.SetupScript, - CleanupScript: req.CleanupScript, - DevScript: req.DevScript, - } - - if err := s.repo.CreateRepository(ctx, repository); err != nil { - s.logger.Error("failed to create repository", zap.Error(err)) - return nil, err - } - - s.publishRepositoryEvent(ctx, events.RepositoryCreated, repository) - s.logger.Info("repository created", zap.String("repository_id", repository.ID)) - return repository, nil -} - -func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) { - return s.repo.GetRepository(ctx, id) -} - -func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRepositoryRequest) (*models.Repository, error) { - repository, err := s.repo.GetRepository(ctx, id) - if err != nil { - return nil, err - } - if req.Name != nil { - repository.Name = *req.Name - } - if req.SourceType != nil { - repository.SourceType = *req.SourceType - } - if req.LocalPath != nil { - repository.LocalPath = *req.LocalPath - } - if req.Provider != nil { - repository.Provider = *req.Provider - } - if req.ProviderRepoID != nil { - repository.ProviderRepoID = *req.ProviderRepoID - } - if req.ProviderOwner != nil { - repository.ProviderOwner = *req.ProviderOwner - } - if req.ProviderName != nil { - repository.ProviderName = *req.ProviderName - } - if req.DefaultBranch != nil { - repository.DefaultBranch = *req.DefaultBranch - } - if req.WorktreeBranchPrefix != nil { - prefix := strings.TrimSpace(*req.WorktreeBranchPrefix) - if err := worktree.ValidateBranchPrefix(prefix); err != nil { - return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) - } - repository.WorktreeBranchPrefix = prefix - } - if req.PullBeforeWorktree != nil { - repository.PullBeforeWorktree = *req.PullBeforeWorktree - } - if req.SetupScript != nil { - repository.SetupScript = *req.SetupScript - } - 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 { - s.logger.Error("failed to update repository", zap.String("repository_id", id), zap.Error(err)) - return nil, err - } - - s.publishRepositoryEvent(ctx, events.RepositoryUpdated, repository) - s.logger.Info("repository updated", zap.String("repository_id", repository.ID)) - return repository, nil -} - -func (s *Service) DeleteRepository(ctx context.Context, id string) error { - repository, err := s.repo.GetRepository(ctx, id) - if err != nil { - return err - } - active, err := s.repo.HasActiveTaskSessionsByRepository(ctx, id) - if err != nil { - s.logger.Error("failed to check active agent sessions for repository", zap.String("repository_id", id), zap.Error(err)) - return err - } - if active { - return ErrActiveTaskSessions - } - if err := s.repo.DeleteRepository(ctx, id); err != nil { - s.logger.Error("failed to delete repository", zap.String("repository_id", id), zap.Error(err)) - return err - } - s.publishRepositoryEvent(ctx, events.RepositoryDeleted, repository) - s.logger.Info("repository deleted", zap.String("repository_id", id)) - return nil -} - -func (s *Service) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { - return s.repo.ListRepositories(ctx, workspaceID) -} - -// Repository script operations - -func (s *Service) CreateRepositoryScript(ctx context.Context, req *CreateRepositoryScriptRequest) (*models.RepositoryScript, error) { - script := &models.RepositoryScript{ - ID: uuid.New().String(), - RepositoryID: req.RepositoryID, - Name: req.Name, - Command: req.Command, - Position: req.Position, - } - if err := s.repo.CreateRepositoryScript(ctx, script); err != nil { - s.logger.Error("failed to create repository script", zap.Error(err)) - return nil, err - } - s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptCreated, script) - s.logger.Info("repository script created", zap.String("script_id", script.ID)) - return script, nil -} - -func (s *Service) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { - return s.repo.GetRepositoryScript(ctx, id) -} - -func (s *Service) UpdateRepositoryScript(ctx context.Context, id string, req *UpdateRepositoryScriptRequest) (*models.RepositoryScript, error) { - script, err := s.repo.GetRepositoryScript(ctx, id) - if err != nil { - return nil, err - } - if req.Name != nil { - script.Name = *req.Name - } - if req.Command != nil { - script.Command = *req.Command - } - if req.Position != nil { - script.Position = *req.Position - } - script.UpdatedAt = time.Now().UTC() - - if err := s.repo.UpdateRepositoryScript(ctx, script); err != nil { - s.logger.Error("failed to update repository script", zap.String("script_id", id), zap.Error(err)) - return nil, err - } - s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptUpdated, script) - s.logger.Info("repository script updated", zap.String("script_id", script.ID)) - return script, nil -} - -func (s *Service) DeleteRepositoryScript(ctx context.Context, id string) error { - script, err := s.repo.GetRepositoryScript(ctx, id) - if err != nil { - return err - } - if err := s.repo.DeleteRepositoryScript(ctx, id); err != nil { - s.logger.Error("failed to delete repository script", zap.String("script_id", id), zap.Error(err)) - return err - } - s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptDeleted, script) - s.logger.Info("repository script deleted", zap.String("script_id", id)) - return nil -} - -func (s *Service) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { - return s.repo.ListRepositoryScripts(ctx, repositoryID) -} - -// Executor operations - -func (s *Service) CreateExecutor(ctx context.Context, req *CreateExecutorRequest) (*models.Executor, error) { - if err := validateExecutorConfig(req.Config); err != nil { - return nil, err - } - executor := &models.Executor{ - ID: uuid.New().String(), - Name: req.Name, - Type: req.Type, - Status: req.Status, - IsSystem: req.IsSystem, - Resumable: req.Resumable, - Config: req.Config, - } - - if err := s.repo.CreateExecutor(ctx, executor); err != nil { - return nil, err - } - s.publishExecutorEvent(ctx, events.ExecutorCreated, executor) - return executor, nil -} - -func (s *Service) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { - return s.repo.GetExecutor(ctx, id) -} - -func (s *Service) UpdateExecutor(ctx context.Context, id string, req *UpdateExecutorRequest) (*models.Executor, error) { - executor, err := s.repo.GetExecutor(ctx, id) - if err != nil { - return nil, err - } - if req.Config != nil { - if err := validateExecutorConfig(req.Config); err != nil { - return nil, err - } - } - if executor.IsSystem { - if req.Name != nil && *req.Name != executor.Name { - return nil, fmt.Errorf("system executors cannot be modified") - } - if req.Type != nil && *req.Type != executor.Type { - return nil, fmt.Errorf("system executors cannot be modified") - } - if req.Status != nil && *req.Status != executor.Status { - return nil, fmt.Errorf("system executors cannot be modified") - } - if req.Resumable != nil && *req.Resumable != executor.Resumable { - return nil, fmt.Errorf("system executors cannot be modified") - } - } - if req.Name != nil { - executor.Name = *req.Name - } - if req.Type != nil { - executor.Type = *req.Type - } - if req.Status != nil { - executor.Status = *req.Status - } - if req.Resumable != nil { - executor.Resumable = *req.Resumable - } - if req.Config != nil { - executor.Config = req.Config - } - executor.UpdatedAt = time.Now().UTC() - if err := s.repo.UpdateExecutor(ctx, executor); err != nil { - return nil, err - } - s.publishExecutorEvent(ctx, events.ExecutorUpdated, executor) - return executor, nil -} - -func (s *Service) DeleteExecutor(ctx context.Context, id string) error { - executor, err := s.repo.GetExecutor(ctx, id) - if err != nil { - return err - } - if executor.IsSystem { - return fmt.Errorf("system executors cannot be deleted") - } - active, err := s.repo.HasActiveTaskSessionsByExecutor(ctx, id) - if err != nil { - s.logger.Error("failed to check active agent sessions for executor", zap.String("executor_id", id), zap.Error(err)) - return err - } - if active { - return ErrActiveTaskSessions - } - if err := s.repo.DeleteExecutor(ctx, id); err != nil { - return err - } - s.publishExecutorEvent(ctx, events.ExecutorDeleted, executor) - return nil -} - -func (s *Service) ListExecutors(ctx context.Context) ([]*models.Executor, error) { - return s.repo.ListExecutors(ctx) -} - -// Environment operations - -func (s *Service) CreateEnvironment(ctx context.Context, req *CreateEnvironmentRequest) (*models.Environment, error) { - environment := &models.Environment{ - ID: uuid.New().String(), - Name: req.Name, - Kind: req.Kind, - IsSystem: false, - WorktreeRoot: req.WorktreeRoot, - ImageTag: req.ImageTag, - Dockerfile: req.Dockerfile, - BuildConfig: req.BuildConfig, - } - if err := s.repo.CreateEnvironment(ctx, environment); err != nil { - return nil, err - } - s.publishEnvironmentEvent(ctx, events.EnvironmentCreated, environment) - return environment, nil -} - -func (s *Service) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { - return s.repo.GetEnvironment(ctx, id) -} - -func (s *Service) UpdateEnvironment(ctx context.Context, id string, req *UpdateEnvironmentRequest) (*models.Environment, error) { - environment, err := s.repo.GetEnvironment(ctx, id) - if err != nil { - return nil, err - } - if environment.IsSystem { - if req.Name != nil || req.Kind != nil || req.ImageTag != nil || req.Dockerfile != nil || req.BuildConfig != nil { - return nil, fmt.Errorf("system environments can only update the worktree root") - } - } - if req.Name != nil { - environment.Name = *req.Name - } - if req.Kind != nil { - environment.Kind = *req.Kind - } - if req.WorktreeRoot != nil { - environment.WorktreeRoot = *req.WorktreeRoot - } - if req.ImageTag != nil { - environment.ImageTag = *req.ImageTag - } - if req.Dockerfile != nil { - environment.Dockerfile = *req.Dockerfile - } - if req.BuildConfig != nil { - environment.BuildConfig = req.BuildConfig - } - environment.UpdatedAt = time.Now().UTC() - if err := s.repo.UpdateEnvironment(ctx, environment); err != nil { - return nil, err - } - s.publishEnvironmentEvent(ctx, events.EnvironmentUpdated, environment) - return environment, nil -} - -func (s *Service) DeleteEnvironment(ctx context.Context, id string) error { - environment, err := s.repo.GetEnvironment(ctx, id) - if err != nil { - return err - } - if environment.IsSystem { - return fmt.Errorf("system environments cannot be deleted") - } - active, err := s.repo.HasActiveTaskSessionsByEnvironment(ctx, id) - if err != nil { - s.logger.Error("failed to check active agent sessions for environment", zap.String("environment_id", id), zap.Error(err)) - return err - } - if active { - return ErrActiveTaskSessions - } - if err := s.repo.DeleteEnvironment(ctx, id); err != nil { - return err - } - s.publishEnvironmentEvent(ctx, events.EnvironmentDeleted, environment) - return nil -} - -func (s *Service) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { - return s.repo.ListEnvironments(ctx) -} - -// publishTaskEvent publishes task events to the event bus -func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) { - if s.eventBus == nil { - return - } - - data := map[string]interface{}{ - "task_id": task.ID, - "workflow_id": task.WorkflowID, - "workflow_step_id": task.WorkflowStepID, - "title": task.Title, - "description": task.Description, - "state": string(task.State), - "priority": task.Priority, - "position": task.Position, - "created_at": task.CreatedAt.Format(time.RFC3339), - "updated_at": task.UpdatedAt.Format(time.RFC3339), - } - - // Fetch session count and primary session info for the task - sessionCountMap, err := s.GetSessionCountsForTasks(ctx, []string{task.ID}) - if err == nil { - if count, ok := sessionCountMap[task.ID]; ok { - data["session_count"] = count - } - } - - primarySessionInfoMap, err := s.GetPrimarySessionInfoForTasks(ctx, []string{task.ID}) - if err == nil { - if sessionInfo, ok := primarySessionInfoMap[task.ID]; ok && sessionInfo != nil { - data["primary_session_id"] = sessionInfo.ID - if sessionInfo.ReviewStatus != nil { - data["review_status"] = *sessionInfo.ReviewStatus - } - } - } - - if task.ArchivedAt != nil { - data["archived_at"] = task.ArchivedAt.Format(time.RFC3339) - } - - if len(task.Repositories) > 0 { - data["repository_id"] = task.Repositories[0].RepositoryID - } - - if task.Metadata != nil { - data["metadata"] = task.Metadata - } - - if oldState != nil { - data["old_state"] = string(*oldState) - data["new_state"] = string(task.State) - } - - event := bus.NewEvent(eventType, "task-service", data) - - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish task event", - zap.String("event_type", eventType), - zap.String("task_id", task.ID), - zap.Error(err)) - } -} - -func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, workspace *models.Workspace) { - if s.eventBus == nil || workspace == nil { - return - } - - data := map[string]interface{}{ - "id": workspace.ID, - "name": workspace.Name, - "description": workspace.Description, - "owner_id": workspace.OwnerID, - "default_executor_id": workspace.DefaultExecutorID, - "default_environment_id": workspace.DefaultEnvironmentID, - "default_agent_profile_id": workspace.DefaultAgentProfileID, - "created_at": workspace.CreatedAt.Format(time.RFC3339), - "updated_at": workspace.UpdatedAt.Format(time.RFC3339), - } - - event := bus.NewEvent(eventType, "task-service", data) - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish workspace event", - zap.String("event_type", eventType), - zap.String("workspace_id", workspace.ID), - zap.Error(err)) - } -} - -func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, workflow *models.Workflow) { - if s.eventBus == nil || workflow == nil { - return - } - - data := map[string]interface{}{ - "id": workflow.ID, - "workspace_id": workflow.WorkspaceID, - "name": workflow.Name, - "description": workflow.Description, - "created_at": workflow.CreatedAt.Format(time.RFC3339), - "updated_at": workflow.UpdatedAt.Format(time.RFC3339), - } - - event := bus.NewEvent(eventType, "task-service", data) - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish workflow event", - zap.String("event_type", eventType), - zap.String("workflow_id", workflow.ID), - zap.Error(err)) - } -} - -func (s *Service) publishExecutorEvent(ctx context.Context, eventType string, executor *models.Executor) { - if s.eventBus == nil || executor == nil { - return - } - - data := map[string]interface{}{ - "id": executor.ID, - "name": executor.Name, - "type": executor.Type, - "status": executor.Status, - "is_system": executor.IsSystem, - "resumable": executor.Resumable, - "config": executor.Config, - "created_at": executor.CreatedAt.Format(time.RFC3339), - "updated_at": executor.UpdatedAt.Format(time.RFC3339), - } - - event := bus.NewEvent(eventType, "task-service", data) - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish executor event", - zap.String("event_type", eventType), - zap.String("executor_id", executor.ID), - zap.Error(err)) - } -} - -func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, environment *models.Environment) { - if s.eventBus == nil || environment == nil { - return - } - - data := map[string]interface{}{ - "id": environment.ID, - "name": environment.Name, - "kind": environment.Kind, - "is_system": environment.IsSystem, - "worktree_root": environment.WorktreeRoot, - "image_tag": environment.ImageTag, - "dockerfile": environment.Dockerfile, - "build_config": environment.BuildConfig, - "created_at": environment.CreatedAt.Format(time.RFC3339), - "updated_at": environment.UpdatedAt.Format(time.RFC3339), - } - - event := bus.NewEvent(eventType, "task-service", data) - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish environment event", - zap.String("event_type", eventType), - zap.String("environment_id", environment.ID), - zap.Error(err)) - } -} - -// Message operations - -// CreateMessageRequest contains the data for creating a new message -type CreateMessageRequest struct { - TaskSessionID string `json:"session_id"` - TaskID string `json:"task_id,omitempty"` - TurnID string `json:"turn_id"` - Content string `json:"content"` - AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" - AuthorID string `json:"author_id,omitempty"` - RequestsInput bool `json:"requests_input,omitempty"` - Type string `json:"type,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -// CreateMessage creates a new message on an agent session -func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error) { - session, err := s.repo.GetTaskSession(ctx, req.TaskSessionID) - if err != nil { - return nil, err - } - - authorType := models.MessageAuthorUser - if req.AuthorType == "agent" { - authorType = models.MessageAuthorAgent - } - - messageType := models.MessageType(req.Type) - if messageType == "" { - messageType = models.MessageTypeMessage - } - - taskID := req.TaskID - if taskID == "" && session != nil { - taskID = session.TaskID - } - - // Ensure we have a turn ID - get active turn or start a new one - turnID := req.TurnID - if turnID == "" { - turn, err := s.getOrStartTurn(ctx, req.TaskSessionID) - if err != nil { - s.logger.Warn("failed to get or start turn for message", - zap.String("session_id", req.TaskSessionID), - zap.Error(err)) - // Continue with empty turn ID - will fail on foreign key if turn is required - } else if turn != nil { - turnID = turn.ID - } - } - - message := &models.Message{ - ID: uuid.New().String(), - TaskSessionID: req.TaskSessionID, - TaskID: taskID, - TurnID: turnID, - AuthorType: authorType, - AuthorID: req.AuthorID, - Content: req.Content, - Type: messageType, - Metadata: req.Metadata, - RequestsInput: req.RequestsInput, - CreatedAt: time.Now().UTC(), - } - - if err := s.repo.CreateMessage(ctx, message); err != nil { - s.logger.Error("failed to create message", zap.Error(err)) - return nil, err - } - - // Publish message.added event - s.publishMessageEvent(ctx, events.MessageAdded, message) - - s.logger.Info("message created", - zap.String("message_id", message.ID), - zap.String("session_id", message.TaskSessionID), - zap.String("author_type", string(message.AuthorType))) - - return message, nil -} - -// CreateMessageWithID creates a new message with a pre-generated ID. -// This is used for streaming messages where the ID is generated by the caller. -// It includes retry logic to handle transient database errors and ensure -// message chunks are not lost during streaming. -func (s *Service) CreateMessageWithID(ctx context.Context, id string, req *CreateMessageRequest) (*models.Message, error) { - const maxRetries = 5 - const retryDelay = 50 * time.Millisecond - - var session *models.TaskSession - var err error - - // Retry loop for getting the session (may not exist yet if events arrive out of order) - for attempt := 0; attempt < maxRetries; attempt++ { - session, err = s.repo.GetTaskSession(ctx, req.TaskSessionID) - if err == nil { - break - } - - if ctx.Err() != nil { - return nil, ctx.Err() - } - - if attempt < maxRetries-1 { - s.logger.Debug("session not found for message create, retrying", - zap.String("session_id", req.TaskSessionID), - zap.String("message_id", id), - zap.Int("attempt", attempt+1), - zap.Int("max_retries", maxRetries)) - time.Sleep(retryDelay) - } - } - - if err != nil { - s.logger.Warn("session not found for message create after retries", - zap.String("session_id", req.TaskSessionID), - zap.String("message_id", id), - zap.Int("retries", maxRetries), - zap.Error(err)) - return nil, err - } - - authorType := models.MessageAuthorUser - if req.AuthorType == "agent" { - authorType = models.MessageAuthorAgent - } - - messageType := models.MessageType(req.Type) - if messageType == "" { - messageType = models.MessageTypeMessage - } - - taskID := req.TaskID - if taskID == "" && session != nil { - taskID = session.TaskID - } - - // Ensure we have a turn ID - get active turn or start a new one - turnID := req.TurnID - if turnID == "" { - turn, err := s.getOrStartTurn(ctx, req.TaskSessionID) - if err != nil { - s.logger.Warn("failed to get or start turn for streaming message", - zap.String("session_id", req.TaskSessionID), - zap.Error(err)) - // Continue with empty turn ID - will fail on foreign key if turn is required - } else if turn != nil { - turnID = turn.ID - } - } - - message := &models.Message{ - ID: id, - TaskSessionID: req.TaskSessionID, - TaskID: taskID, - TurnID: turnID, - AuthorType: authorType, - AuthorID: req.AuthorID, - Content: req.Content, - Type: messageType, - Metadata: req.Metadata, - RequestsInput: req.RequestsInput, - CreatedAt: time.Now().UTC(), - } - - // Retry loop for creating the message (handles transient DB errors) - for attempt := 0; attempt < maxRetries; attempt++ { - err = s.repo.CreateMessage(ctx, message) - if err == nil { - break - } - - if ctx.Err() != nil { - return nil, ctx.Err() - } - - if attempt < maxRetries-1 { - s.logger.Debug("failed to create message, retrying", - zap.String("message_id", id), - zap.Int("attempt", attempt+1), - zap.Int("max_retries", maxRetries), - zap.Error(err)) - time.Sleep(retryDelay) - } - } - - if err != nil { - s.logger.Error("failed to create message with ID after retries", - zap.String("id", id), - zap.Int("retries", maxRetries), - zap.Error(err)) - return nil, err - } - - // Publish message.added event - s.publishMessageEvent(ctx, events.MessageAdded, message) - - s.logger.Info("message created with ID", - zap.String("message_id", message.ID), - zap.String("session_id", message.TaskSessionID), - zap.String("author_type", string(message.AuthorType))) - - return message, nil -} - -// GetMessage retrieves a message by ID -func (s *Service) GetMessage(ctx context.Context, id string) (*models.Message, error) { - return s.repo.GetMessage(ctx, id) -} - -// ListMessages returns all messages for a session. -func (s *Service) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { - return s.repo.ListMessages(ctx, sessionID) -} - -// ListMessagesPaginated returns messages for a session with pagination options. -func (s *Service) ListMessagesPaginated(ctx context.Context, req ListMessagesRequest) ([]*models.Message, bool, error) { - limit := req.Limit - if limit <= 0 && (req.Before != "" || req.After != "") { - limit = DefaultMessagesPageSize - } - if limit > MaxMessagesPageSize { - limit = MaxMessagesPageSize - } - return s.repo.ListMessagesPaginated(ctx, req.TaskSessionID, models.ListMessagesOptions{ - Limit: limit, - Before: req.Before, - After: req.After, - Sort: req.Sort, - }) -} - -// DeleteMessage deletes a message -func (s *Service) DeleteMessage(ctx context.Context, id string) error { - if err := s.repo.DeleteMessage(ctx, id); err != nil { - s.logger.Error("failed to delete message", zap.String("message_id", id), zap.Error(err)) - return err - } - - s.logger.Info("message deleted", zap.String("message_id", id)) - return nil -} - -// UpdateMessage updates an existing message and publishes an event. -func (s *Service) UpdateMessage(ctx context.Context, message *models.Message) error { - if err := s.repo.UpdateMessage(ctx, message); err != nil { - s.logger.Error("failed to update message", - zap.String("message_id", message.ID), - zap.Error(err)) - return err - } - - // Publish message.updated event for real-time streaming - s.publishMessageEvent(ctx, events.MessageUpdated, message) - - return nil -} - -// AppendMessageContent appends additional content to an existing message. -// This is used for streaming agent responses where content arrives incrementally. -func (s *Service) AppendMessageContent(ctx context.Context, messageID, additionalContent string) error { - message, err := s.repo.GetMessage(ctx, messageID) - if err != nil { - s.logger.Warn("message not found for append", - zap.String("message_id", messageID), - zap.Error(err)) - return err - } - - // Append the new content - message.Content = message.Content + additionalContent - - if err := s.repo.UpdateMessage(ctx, message); err != nil { - s.logger.Error("failed to append message content", - zap.String("message_id", messageID), - zap.Error(err)) - return err - } - - // Publish message.updated event for real-time streaming - s.publishMessageEvent(ctx, events.MessageUpdated, message) - - s.logger.Debug("message content appended", - zap.String("message_id", messageID), - zap.Int("appended_length", len(additionalContent)), - zap.Int("total_length", len(message.Content))) - - return nil -} - -// AppendThinkingContent appends additional thinking content to an existing thinking message. -// This updates the metadata.thinking field for streaming agent reasoning. -func (s *Service) AppendThinkingContent(ctx context.Context, messageID, additionalContent string) error { - message, err := s.repo.GetMessage(ctx, messageID) - if err != nil { - s.logger.Warn("thinking message not found for append", - zap.String("message_id", messageID), - zap.Error(err)) - return err - } - - // Initialize metadata if nil - if message.Metadata == nil { - message.Metadata = make(map[string]interface{}) - } - - // Get existing thinking content and append - existingThinking := "" - if existing, ok := message.Metadata["thinking"].(string); ok { - existingThinking = existing - } - message.Metadata["thinking"] = existingThinking + additionalContent - - if err := s.repo.UpdateMessage(ctx, message); err != nil { - s.logger.Error("failed to append thinking content", - zap.String("message_id", messageID), - zap.Error(err)) - return err - } - - // Publish message.updated event for real-time streaming - s.publishMessageEvent(ctx, events.MessageUpdated, message) - - s.logger.Debug("thinking content appended", - zap.String("message_id", messageID), - zap.Int("appended_length", len(additionalContent))) - - return nil -} - -// UpdateToolCallMessage updates a tool call message's status, optionally title and normalized data. -// It includes retry logic to handle race conditions where the complete event -// may arrive before the message has been created by the start event. -// If the message is not found after retries and taskID/turnID/msgType are provided, it creates the message. -// The normalized parameter contains typed tool payload data that gets added to metadata. -func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result, title string, normalized *streams.NormalizedPayload) error { - return s.UpdateToolCallMessageWithCreate(ctx, sessionID, toolCallID, "", status, result, title, normalized, "", "", "") -} - -// UpdateToolCallMessageWithCreate is like UpdateToolCallMessage but can create the message if not found. -// If taskID, turnID, and msgType are provided, the message will be created if it doesn't exist. -// parentToolCallID is used for subagent nesting (empty for top-level). -func (s *Service) UpdateToolCallMessageWithCreate(ctx context.Context, sessionID, toolCallID, parentToolCallID, status, result, title string, normalized *streams.NormalizedPayload, taskID, turnID, msgType string) error { - const maxRetries = 5 - const retryDelay = 100 * time.Millisecond - - var message *models.Message - var err error - - // Retry loop to handle race condition where complete event arrives before start event - // has finished creating the message in the database - for attempt := 0; attempt < maxRetries; attempt++ { - message, err = s.repo.GetMessageByToolCallID(ctx, sessionID, toolCallID) - if err == nil { - break // Found the message, proceed with update - } - - // If context is cancelled, don't retry - if ctx.Err() != nil { - return ctx.Err() - } - - // Log retry attempt (only warn on final failure) - if attempt < maxRetries-1 { - s.logger.Debug("tool call message not found, retrying", - zap.String("session_id", sessionID), - zap.String("tool_call_id", toolCallID), - zap.Int("attempt", attempt+1), - zap.Int("max_retries", maxRetries)) - time.Sleep(retryDelay) - } - } - - // If message not found and we have enough info to create it, do so - if err != nil && taskID != "" && msgType != "" { - s.logger.Info("tool call message not found, creating it", - zap.String("session_id", sessionID), - zap.String("tool_call_id", toolCallID), - zap.String("task_id", taskID), - zap.String("msg_type", msgType)) - - metadata := map[string]interface{}{ - "tool_call_id": toolCallID, - "title": title, - "status": status, - } - // Add parent tool call ID for subagent nesting (if present) - if parentToolCallID != "" { - metadata["parent_tool_call_id"] = parentToolCallID - } - if normalized != nil { - metadata["normalized"] = normalized - } - - msg, createErr := s.CreateMessage(ctx, &CreateMessageRequest{ - TaskSessionID: sessionID, - TaskID: taskID, - TurnID: turnID, - Content: title, - AuthorType: "agent", - Type: msgType, - Metadata: metadata, - }) - if createErr != nil { - s.logger.Error("failed to create tool call message as fallback", - zap.String("session_id", sessionID), - zap.String("tool_call_id", toolCallID), - zap.Error(createErr)) - return createErr - } - - s.logger.Info("created tool call message as fallback", - zap.String("message_id", msg.ID), - zap.String("tool_call_id", toolCallID), - zap.String("status", status)) - return nil - } - - if err != nil { - s.logger.Warn("tool call message not found for update after retries", - zap.String("session_id", sessionID), - zap.String("tool_call_id", toolCallID), - zap.Int("retries", maxRetries), - zap.Error(err)) - return err - } - - if message.Metadata == nil { - message.Metadata = make(map[string]interface{}) - } - message.Metadata["status"] = status - if result != "" { - message.Metadata["result"] = result - } - - // Add normalized tool data to metadata for frontend consumption - // and update message type if the normalized kind changed (e.g., read_file -> code_search for directories) - if normalized != nil { - message.Metadata["normalized"] = normalized - - // Update message type if the normalized kind changed - // This handles cases like Read on a directory converting to code_search - newMsgType := models.MessageType(normalized.Kind().ToMessageType()) - if newMsgType != message.Type { - s.logger.Debug("updating message type based on normalized kind", - zap.String("message_id", message.ID), - zap.String("old_type", string(message.Type)), - zap.String("new_type", string(newMsgType)), - zap.String("normalized_kind", string(normalized.Kind()))) - message.Type = newMsgType - } - } - - // Update title/content if provided and different from current - // This handles the case where the first event only had the tool name (e.g., "bash") - // and subsequent events have the actual command/path (e.g., "ls" or "/path/to/file") - if title != "" && title != message.Content { - message.Content = title - message.Metadata["title"] = title - } - - if err := s.repo.UpdateMessage(ctx, message); err != nil { - s.logger.Error("failed to update tool call message", - zap.String("message_id", message.ID), - zap.String("tool_call_id", toolCallID), - zap.Error(err)) - return err - } - - // Publish message.updated event - s.publishMessageEvent(ctx, events.MessageUpdated, message) - - s.logger.Info("tool call message updated", - zap.String("message_id", message.ID), - zap.String("tool_call_id", toolCallID), - zap.String("status", status)) - - return nil -} - -// UpdatePermissionMessage updates a permission request message's status. -// It includes retry logic to handle race conditions. -func (s *Service) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error { - const maxRetries = 5 - const retryDelay = 100 * time.Millisecond - - var message *models.Message - var err error - - // Retry loop to handle race condition - for attempt := 0; attempt < maxRetries; attempt++ { - message, err = s.repo.GetMessageByPendingID(ctx, sessionID, pendingID) - if err == nil { - break - } - - if ctx.Err() != nil { - return ctx.Err() - } - - if attempt < maxRetries-1 { - s.logger.Debug("permission message not found, retrying", - zap.String("session_id", sessionID), - zap.String("pending_id", pendingID), - zap.Int("attempt", attempt+1), - zap.Int("max_retries", maxRetries)) - time.Sleep(retryDelay) - } - } - - if err != nil { - s.logger.Warn("permission message not found for update after retries", - zap.String("session_id", sessionID), - zap.String("pending_id", pendingID), - zap.Int("retries", maxRetries), - zap.Error(err)) - return err - } - - if message.Metadata == nil { - message.Metadata = make(map[string]interface{}) - } - message.Metadata["status"] = status - - if err := s.repo.UpdateMessage(ctx, message); err != nil { - s.logger.Error("failed to update permission message", - zap.String("message_id", message.ID), - zap.String("pending_id", pendingID), - zap.Error(err)) - return err - } - - // Publish message.updated event - s.publishMessageEvent(ctx, events.MessageUpdated, message) - - // When a permission expires, also mark the related tool call as cancelled - // so the UI no longer shows a loading spinner on the tool call. - if status == "expired" { - if toolCallID, ok := message.Metadata["tool_call_id"].(string); ok && toolCallID != "" { - if err := s.UpdateToolCallMessage(ctx, sessionID, toolCallID, "error", "", "", nil); err != nil { - s.logger.Warn("failed to cancel related tool call message", - zap.String("tool_call_id", toolCallID), - zap.String("pending_id", pendingID), - zap.Error(err)) - } - } - } - - s.logger.Info("permission message updated", - zap.String("message_id", message.ID), - zap.String("pending_id", pendingID), - zap.String("status", status)) - - return nil -} - -// UpdateClarificationMessage updates a clarification request message's status and response. -// It includes retry logic to handle race conditions. -// The answers parameter should be a slice of answer objects with question_id, selected_options, and custom_text. -func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answers interface{}) error { - const maxRetries = 5 - const retryDelay = 100 * time.Millisecond - - var message *models.Message - var err error - - // Retry loop to handle race condition - for attempt := 0; attempt < maxRetries; attempt++ { - message, err = s.repo.GetMessageByPendingID(ctx, sessionID, pendingID) - if err == nil { - break - } - - if ctx.Err() != nil { - return ctx.Err() - } - - if attempt < maxRetries-1 { - s.logger.Debug("clarification message not found, retrying", - zap.String("session_id", sessionID), - zap.String("pending_id", pendingID), - zap.Int("attempt", attempt+1), - zap.Int("max_retries", maxRetries)) - time.Sleep(retryDelay) - } - } - - if err != nil { - s.logger.Warn("clarification message not found for update after retries", - zap.String("session_id", sessionID), - zap.String("pending_id", pendingID), - zap.Int("retries", maxRetries), - zap.Error(err)) - return err - } - - if message.Metadata == nil { - message.Metadata = make(map[string]interface{}) - } - message.Metadata["status"] = status - if answers != nil { - message.Metadata["response"] = answers - } - - if err := s.repo.UpdateMessage(ctx, message); err != nil { - s.logger.Error("failed to update clarification message", - zap.String("message_id", message.ID), - zap.String("pending_id", pendingID), - zap.Error(err)) - return err - } - - // Publish message.updated event - s.publishMessageEvent(ctx, events.MessageUpdated, message) - - s.logger.Info("clarification message updated", - zap.String("message_id", message.ID), - zap.String("pending_id", pendingID), - zap.String("status", status)) - - return nil -} - -// Turn operations - -// StartTurn creates a new turn for a session and publishes the turn.started event. -// Returns the created turn. -func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("failed to get session: %w", err) - } - - turn := &models.Turn{ - ID: uuid.New().String(), - TaskSessionID: sessionID, - TaskID: session.TaskID, - StartedAt: time.Now().UTC(), - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - - if err := s.repo.CreateTurn(ctx, turn); err != nil { - s.logger.Error("failed to create turn", zap.Error(err)) - return nil, err - } - - s.publishTurnEvent(events.TurnStarted, turn) - - s.logger.Debug("started turn", - zap.String("turn_id", turn.ID), - zap.String("session_id", sessionID), - zap.String("task_id", turn.TaskID)) - - return turn, nil -} - -// CompleteTurn marks a turn as completed and publishes the turn.completed event. -func (s *Service) CompleteTurn(ctx context.Context, turnID string) error { - if turnID == "" { - return nil // No active turn to complete - } - - if err := s.repo.CompleteTurn(ctx, turnID); err != nil { - s.logger.Error("failed to complete turn", zap.String("turn_id", turnID), zap.Error(err)) - return err - } - - // Safety net: mark any tool calls still in "running" state as "complete" - if affected, err := s.repo.CompleteRunningToolCallsForTurn(ctx, turnID); err != nil { - s.logger.Warn("failed to complete running tool calls for turn", zap.String("turn_id", turnID), zap.Error(err)) - } else if affected > 0 { - s.logger.Info("completed stale running tool calls on turn end", - zap.String("turn_id", turnID), - zap.Int64("affected", affected)) - } - - // Fetch the completed turn to get the completed_at timestamp - turn, err := s.repo.GetTurn(ctx, turnID) - if err != nil { - s.logger.Warn("failed to refetch completed turn", zap.String("turn_id", turnID), zap.Error(err)) - // Turn was likely deleted (task deletion race), skip publishing - return nil - } - - s.publishTurnEvent(events.TurnCompleted, turn) - - s.logger.Debug("completed turn", - zap.String("turn_id", turnID), - zap.String("session_id", turn.TaskSessionID), - zap.String("task_id", turn.TaskID)) - - return nil -} - -// GetActiveTurn returns the currently active (non-completed) turn for a session. -func (s *Service) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) { - return s.repo.GetActiveTurnBySessionID(ctx, sessionID) -} - -// getOrStartTurn returns the active turn for a session, or starts a new one if none exists. -// This is used to ensure messages always have a valid turn ID. -func (s *Service) getOrStartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { - // First try to get an active turn - turn, err := s.repo.GetActiveTurnBySessionID(ctx, sessionID) - if err == nil && turn != nil { - return turn, nil - } - - // No active turn, start a new one - return s.StartTurn(ctx, sessionID) -} - -// publishTurnEvent publishes a turn event to the event bus. -func (s *Service) publishTurnEvent(eventType string, turn *models.Turn) { - if s.eventBus == nil { - return - } - if turn == nil { - s.logger.Warn("publishTurnEvent: turn is nil, skipping", zap.String("event_type", eventType)) - return - } - _ = s.eventBus.Publish(context.Background(), eventType, bus.NewEvent(eventType, "task-service", map[string]interface{}{ - "id": turn.ID, - "session_id": turn.TaskSessionID, - "task_id": turn.TaskID, - "started_at": turn.StartedAt, - "completed_at": turn.CompletedAt, - "metadata": turn.Metadata, - "created_at": turn.CreatedAt, - "updated_at": turn.UpdatedAt, - })) -} - -// publishMessageEvent publishes message events to the event bus. -// System-injected content (wrapped in tags) is stripped from the content -// so users don't see workflow step prompt modifications in the UI. -func (s *Service) publishMessageEvent(ctx context.Context, eventType string, message *models.Message) { - if s.eventBus == nil { - s.logger.Warn("publishMessageEvent: eventBus is nil, skipping") - return - } - - messageType := string(message.Type) - if messageType == "" { - messageType = "message" - } - - data := map[string]interface{}{ - "message_id": message.ID, - "session_id": message.TaskSessionID, - "task_id": message.TaskID, - "turn_id": message.TurnID, - "author_type": string(message.AuthorType), - "author_id": message.AuthorID, - "content": sysprompt.StripSystemContent(message.Content), - "type": messageType, - "requests_input": message.RequestsInput, - "created_at": message.CreatedAt.Format(time.RFC3339), - } - - if message.Metadata != nil { - data["metadata"] = message.Metadata - } - - event := bus.NewEvent(eventType, "task-service", data) - - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish message event", - zap.String("event_type", eventType), - zap.String("message_id", message.ID), - zap.Error(err)) - } -} - -func (s *Service) publishRepositoryEvent(ctx context.Context, eventType string, repository *models.Repository) { - if s.eventBus == nil || repository == nil { - return - } - data := map[string]interface{}{ - "id": repository.ID, - "workspace_id": repository.WorkspaceID, - "name": repository.Name, - "source_type": repository.SourceType, - "local_path": repository.LocalPath, - "provider": repository.Provider, - "provider_repo_id": repository.ProviderRepoID, - "provider_owner": repository.ProviderOwner, - "provider_name": repository.ProviderName, - "default_branch": repository.DefaultBranch, - "worktree_branch_prefix": repository.WorktreeBranchPrefix, - "pull_before_worktree": repository.PullBeforeWorktree, - "setup_script": repository.SetupScript, - "cleanup_script": repository.CleanupScript, - "created_at": repository.CreatedAt.Format(time.RFC3339), - "updated_at": repository.UpdatedAt.Format(time.RFC3339), - } - event := bus.NewEvent(eventType, "task-service", data) - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish repository event", - zap.String("event_type", eventType), - zap.String("repository_id", repository.ID), - zap.Error(err)) - } -} - -func (s *Service) publishRepositoryScriptEvent(ctx context.Context, eventType string, script *models.RepositoryScript) { - if s.eventBus == nil || script == nil { - return - } - data := map[string]interface{}{ - "id": script.ID, - "repository_id": script.RepositoryID, - "name": script.Name, - "command": script.Command, - "position": script.Position, - "created_at": script.CreatedAt.Format(time.RFC3339), - "updated_at": script.UpdatedAt.Format(time.RFC3339), - } - event := bus.NewEvent(eventType, "task-service", data) - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish repository script event", - zap.String("event_type", eventType), - zap.String("script_id", script.ID), - zap.Error(err)) - } -} - -// GetGitSnapshots retrieves git snapshots for a session -func (s *Service) GetGitSnapshots(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { - return s.repo.GetGitSnapshotsBySession(ctx, sessionID, limit) -} - -// GetLatestGitSnapshot retrieves the latest git snapshot for a session -func (s *Service) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { - return s.repo.GetLatestGitSnapshot(ctx, sessionID) -} - -// GetFirstGitSnapshot retrieves the first git snapshot for a session (oldest) -func (s *Service) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { - return s.repo.GetFirstGitSnapshot(ctx, sessionID) -} - -// GetSessionCommits retrieves commits for a session -func (s *Service) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { - return s.repo.GetSessionCommits(ctx, sessionID) -} - -// GetLatestSessionCommit retrieves the latest commit for a session -func (s *Service) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) { - return s.repo.GetLatestSessionCommit(ctx, sessionID) -} - -// GetCumulativeDiff computes the cumulative diff from base commit to current HEAD -// by using the first snapshot's base_commit and the latest snapshot's files -func (s *Service) GetCumulativeDiff(ctx context.Context, sessionID string) (*models.CumulativeDiff, error) { - // Get the first snapshot to find the base commit - firstSnapshot, err := s.repo.GetFirstGitSnapshot(ctx, sessionID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil // No snapshots yet — valid state for fresh tasks - } - return nil, fmt.Errorf("failed to get first git snapshot: %w", err) - } - - // Get the latest snapshot for current state - latestSnapshot, err := s.repo.GetLatestGitSnapshot(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("failed to get latest git snapshot: %w", err) - } - - // Count total commits for this session - commits, err := s.repo.GetSessionCommits(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("failed to get session commits: %w", err) - } - - return &models.CumulativeDiff{ - SessionID: sessionID, - BaseCommit: firstSnapshot.BaseCommit, - HeadCommit: latestSnapshot.HeadCommit, - TotalCommits: len(commits), - Files: latestSnapshot.Files, - }, nil -} - -// GetWorkspaceInfoForSession returns workspace information for a task session. -// This implements the lifecycle.WorkspaceInfoProvider interface. -// The taskID parameter is optional - if empty, it will be looked up from the session. -func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessionID string) (*lifecycle.WorkspaceInfo, error) { - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("failed to get session %s: %w", sessionID, err) - } - if session == nil { - return nil, fmt.Errorf("session %s not found", sessionID) - } - - // Use session's TaskID if not provided - if taskID == "" { - taskID = session.TaskID - } - - // Get workspace path from the session's worktree - var workspacePath string - if len(session.Worktrees) > 0 { - workspacePath = session.Worktrees[0].WorktreePath - } - - // If no worktree, try to get from repository snapshot - if workspacePath == "" && session.RepositorySnapshot != nil { - if path, ok := session.RepositorySnapshot["path"].(string); ok { - workspacePath = path - } - } - - // Get agent ID from profile snapshot - var agentID string - if session.AgentProfileSnapshot != nil { - if id, ok := session.AgentProfileSnapshot["agent_id"].(string); ok { - agentID = id - } - } - - // Get ACP session ID from metadata - var acpSessionID string - if session.Metadata != nil { - if id, ok := session.Metadata["acp_session_id"].(string); ok { - acpSessionID = id - } - } - - return &lifecycle.WorkspaceInfo{ - TaskID: taskID, - SessionID: sessionID, - WorkspacePath: workspacePath, - AgentProfileID: session.AgentProfileID, - AgentID: agentID, - ACPSessionID: acpSessionID, - }, nil -} diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go new file mode 100644 index 0000000000..f35e95b39a --- /dev/null +++ b/apps/backend/internal/task/service/service_events.go @@ -0,0 +1,260 @@ +package service + +import ( + "context" + "time" + + "go.uber.org/zap" + + v1 "github.com/kandev/kandev/pkg/api/v1" + + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" +) + +// publishTaskEvent publishes task events to the event bus +func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) { + if s.eventBus == nil { + return + } + + data := map[string]interface{}{ + "task_id": task.ID, + "workflow_id": task.WorkflowID, + "workflow_step_id": task.WorkflowStepID, + "title": task.Title, + "description": task.Description, + "state": string(task.State), + "priority": task.Priority, + "position": task.Position, + "created_at": task.CreatedAt.Format(time.RFC3339), + "updated_at": task.UpdatedAt.Format(time.RFC3339), + } + + // Fetch session count and primary session info for the task + sessionCountMap, err := s.GetSessionCountsForTasks(ctx, []string{task.ID}) + if err == nil { + if count, ok := sessionCountMap[task.ID]; ok { + data["session_count"] = count + } + } + + primarySessionInfoMap, err := s.GetPrimarySessionInfoForTasks(ctx, []string{task.ID}) + if err == nil { + if sessionInfo, ok := primarySessionInfoMap[task.ID]; ok && sessionInfo != nil { + data["primary_session_id"] = sessionInfo.ID + if sessionInfo.ReviewStatus != nil { + data["review_status"] = *sessionInfo.ReviewStatus + } + } + } + + if task.ArchivedAt != nil { + data["archived_at"] = task.ArchivedAt.Format(time.RFC3339) + } + + if len(task.Repositories) > 0 { + data["repository_id"] = task.Repositories[0].RepositoryID + } + + if task.Metadata != nil { + data["metadata"] = task.Metadata + } + + if oldState != nil { + data["old_state"] = string(*oldState) + data["new_state"] = string(task.State) + } + + event := bus.NewEvent(eventType, "task-service", data) + + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish task event", + zap.String("event_type", eventType), + zap.String("task_id", task.ID), + zap.Error(err)) + } +} + +func (s *Service) publishEventToBus(ctx context.Context, eventType, resourceType, resourceID string, data map[string]interface{}) { + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish "+resourceType+" event", + zap.String("event_type", eventType), + zap.String(resourceType+"_id", resourceID), + zap.Error(err)) + } +} + +func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, workspace *models.Workspace) { + if s.eventBus == nil || workspace == nil { + return + } + + data := map[string]interface{}{ + "id": workspace.ID, + "name": workspace.Name, + "description": workspace.Description, + "owner_id": workspace.OwnerID, + "default_executor_id": workspace.DefaultExecutorID, + "default_environment_id": workspace.DefaultEnvironmentID, + "default_agent_profile_id": workspace.DefaultAgentProfileID, + "created_at": workspace.CreatedAt.Format(time.RFC3339), + "updated_at": workspace.UpdatedAt.Format(time.RFC3339), + } + + s.publishEventToBus(ctx, eventType, "workspace", workspace.ID, data) +} + +func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, workflow *models.Workflow) { + if s.eventBus == nil || workflow == nil { + return + } + + data := map[string]interface{}{ + "id": workflow.ID, + "workspace_id": workflow.WorkspaceID, + "name": workflow.Name, + "description": workflow.Description, + "created_at": workflow.CreatedAt.Format(time.RFC3339), + "updated_at": workflow.UpdatedAt.Format(time.RFC3339), + } + + s.publishEventToBus(ctx, eventType, "workflow", workflow.ID, data) +} + +func (s *Service) publishExecutorEvent(ctx context.Context, eventType string, executor *models.Executor) { + if s.eventBus == nil || executor == nil { + return + } + + data := map[string]interface{}{ + "id": executor.ID, + "name": executor.Name, + "type": executor.Type, + "status": executor.Status, + "is_system": executor.IsSystem, + "resumable": executor.Resumable, + "config": executor.Config, + "created_at": executor.CreatedAt.Format(time.RFC3339), + "updated_at": executor.UpdatedAt.Format(time.RFC3339), + } + + s.publishEventToBus(ctx, eventType, "executor", executor.ID, data) +} + +func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, environment *models.Environment) { + if s.eventBus == nil || environment == nil { + return + } + + data := map[string]interface{}{ + "id": environment.ID, + "name": environment.Name, + "kind": environment.Kind, + "is_system": environment.IsSystem, + "worktree_root": environment.WorktreeRoot, + "image_tag": environment.ImageTag, + "dockerfile": environment.Dockerfile, + "build_config": environment.BuildConfig, + "created_at": environment.CreatedAt.Format(time.RFC3339), + "updated_at": environment.UpdatedAt.Format(time.RFC3339), + } + + s.publishEventToBus(ctx, eventType, "environment", environment.ID, data) +} + +// publishMessageEvent publishes message events to the event bus. +// System-injected content (wrapped in tags) is stripped from the content +// so users don't see workflow step prompt modifications in the UI. +func (s *Service) publishMessageEvent(ctx context.Context, eventType string, message *models.Message) { + if s.eventBus == nil { + s.logger.Warn("publishMessageEvent: eventBus is nil, skipping") + return + } + + messageType := string(message.Type) + if messageType == "" { + messageType = "message" + } + + data := map[string]interface{}{ + "message_id": message.ID, + "session_id": message.TaskSessionID, + "task_id": message.TaskID, + "turn_id": message.TurnID, + "author_type": string(message.AuthorType), + "author_id": message.AuthorID, + "content": sysprompt.StripSystemContent(message.Content), + "type": messageType, + "requests_input": message.RequestsInput, + "created_at": message.CreatedAt.Format(time.RFC3339), + } + + if message.Metadata != nil { + data["metadata"] = message.Metadata + } + + event := bus.NewEvent(eventType, "task-service", data) + + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish message event", + zap.String("event_type", eventType), + zap.String("message_id", message.ID), + zap.Error(err)) + } +} + +func (s *Service) publishRepositoryEvent(ctx context.Context, eventType string, repository *models.Repository) { + if s.eventBus == nil || repository == nil { + return + } + data := map[string]interface{}{ + "id": repository.ID, + "workspace_id": repository.WorkspaceID, + "name": repository.Name, + "source_type": repository.SourceType, + "local_path": repository.LocalPath, + "provider": repository.Provider, + "provider_repo_id": repository.ProviderRepoID, + "provider_owner": repository.ProviderOwner, + "provider_name": repository.ProviderName, + "default_branch": repository.DefaultBranch, + "worktree_branch_prefix": repository.WorktreeBranchPrefix, + "pull_before_worktree": repository.PullBeforeWorktree, + "setup_script": repository.SetupScript, + "cleanup_script": repository.CleanupScript, + "created_at": repository.CreatedAt.Format(time.RFC3339), + "updated_at": repository.UpdatedAt.Format(time.RFC3339), + } + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish repository event", + zap.String("event_type", eventType), + zap.String("repository_id", repository.ID), + zap.Error(err)) + } +} + +func (s *Service) publishRepositoryScriptEvent(ctx context.Context, eventType string, script *models.RepositoryScript) { + if s.eventBus == nil || script == nil { + return + } + data := map[string]interface{}{ + "id": script.ID, + "repository_id": script.RepositoryID, + "name": script.Name, + "command": script.Command, + "position": script.Position, + "created_at": script.CreatedAt.Format(time.RFC3339), + "updated_at": script.UpdatedAt.Format(time.RFC3339), + } + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish repository script event", + zap.String("event_type", eventType), + zap.String("script_id", script.ID), + zap.Error(err)) + } +} diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go new file mode 100644 index 0000000000..bc84a4e5fb --- /dev/null +++ b/apps/backend/internal/task/service/service_messages.go @@ -0,0 +1,629 @@ +package service + +import ( + "context" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" +) + +// CreateMessage creates a new message on an agent session +func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error) { + session, err := s.repo.GetTaskSession(ctx, req.TaskSessionID) + if err != nil { + return nil, err + } + + authorType := models.MessageAuthorUser + if req.AuthorType == "agent" { + authorType = models.MessageAuthorAgent + } + + messageType := models.MessageType(req.Type) + if messageType == "" { + messageType = models.MessageTypeMessage + } + + taskID := req.TaskID + if taskID == "" && session != nil { + taskID = session.TaskID + } + + // Ensure we have a turn ID - get active turn or start a new one + turnID := req.TurnID + if turnID == "" { + turn, err := s.getOrStartTurn(ctx, req.TaskSessionID) + if err != nil { + s.logger.Warn("failed to get or start turn for message", + zap.String("session_id", req.TaskSessionID), + zap.Error(err)) + // Continue with empty turn ID - will fail on foreign key if turn is required + } else if turn != nil { + turnID = turn.ID + } + } + + message := &models.Message{ + ID: uuid.New().String(), + TaskSessionID: req.TaskSessionID, + TaskID: taskID, + TurnID: turnID, + AuthorType: authorType, + AuthorID: req.AuthorID, + Content: req.Content, + Type: messageType, + Metadata: req.Metadata, + RequestsInput: req.RequestsInput, + CreatedAt: time.Now().UTC(), + } + + if err := s.repo.CreateMessage(ctx, message); err != nil { + s.logger.Error("failed to create message", zap.Error(err)) + return nil, err + } + + // Publish message.added event + s.publishMessageEvent(ctx, events.MessageAdded, message) + + s.logger.Info("message created", + zap.String("message_id", message.ID), + zap.String("session_id", message.TaskSessionID), + zap.String("author_type", string(message.AuthorType))) + + return message, nil +} + +// CreateMessageWithID creates a new message with a pre-generated ID. +// This is used for streaming messages where the ID is generated by the caller. +// It includes retry logic to handle transient database errors and ensure +// message chunks are not lost during streaming. +func (s *Service) CreateMessageWithID(ctx context.Context, id string, req *CreateMessageRequest) (*models.Message, error) { + const maxRetries = 5 + const retryDelay = 50 * time.Millisecond + + session, err := s.getSessionWithRetry(ctx, req.TaskSessionID, id, maxRetries, retryDelay) + if err != nil { + return nil, err + } + + message := s.buildMessage(ctx, id, req, session) + + if err := s.createMessageWithRetry(ctx, message, maxRetries, retryDelay); err != nil { + return nil, err + } + + // Publish message.added event + s.publishMessageEvent(ctx, events.MessageAdded, message) + + s.logger.Info("message created with ID", + zap.String("message_id", message.ID), + zap.String("session_id", message.TaskSessionID), + zap.String("author_type", string(message.AuthorType))) + + return message, nil +} + +// getSessionWithRetry fetches a session, retrying on transient errors caused by out-of-order events. +func (s *Service) getSessionWithRetry(ctx context.Context, sessionID, messageID string, maxRetries int, retryDelay time.Duration) (*models.TaskSession, error) { + var session *models.TaskSession + var err error + for attempt := 0; attempt < maxRetries; attempt++ { + session, err = s.repo.GetTaskSession(ctx, sessionID) + if err == nil { + return session, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if attempt < maxRetries-1 { + s.logger.Debug("session not found for message create, retrying", + zap.String("session_id", sessionID), + zap.String("message_id", messageID), + zap.Int("attempt", attempt+1), + zap.Int("max_retries", maxRetries)) + time.Sleep(retryDelay) + } + } + s.logger.Warn("session not found for message create after retries", + zap.String("session_id", sessionID), + zap.String("message_id", messageID), + zap.Int("retries", maxRetries), + zap.Error(err)) + return nil, err +} + +// buildMessage constructs a Message model from a CreateMessageRequest and resolved session. +func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessageRequest, session *models.TaskSession) *models.Message { + authorType := models.MessageAuthorUser + if req.AuthorType == "agent" { + authorType = models.MessageAuthorAgent + } + + messageType := models.MessageType(req.Type) + if messageType == "" { + messageType = models.MessageTypeMessage + } + + taskID := req.TaskID + if taskID == "" && session != nil { + taskID = session.TaskID + } + + turnID := req.TurnID + if turnID == "" { + if turn, err := s.getOrStartTurn(ctx, req.TaskSessionID); err != nil { + s.logger.Warn("failed to get or start turn for streaming message", + zap.String("session_id", req.TaskSessionID), + zap.Error(err)) + } else if turn != nil { + turnID = turn.ID + } + } + + return &models.Message{ + ID: id, + TaskSessionID: req.TaskSessionID, + TaskID: taskID, + TurnID: turnID, + AuthorType: authorType, + AuthorID: req.AuthorID, + Content: req.Content, + Type: messageType, + Metadata: req.Metadata, + RequestsInput: req.RequestsInput, + CreatedAt: time.Now().UTC(), + } +} + +// createMessageWithRetry persists a message with retry logic for transient DB errors. +func (s *Service) createMessageWithRetry(ctx context.Context, message *models.Message, maxRetries int, retryDelay time.Duration) error { + var err error + for attempt := 0; attempt < maxRetries; attempt++ { + err = s.repo.CreateMessage(ctx, message) + if err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if attempt < maxRetries-1 { + s.logger.Debug("failed to create message, retrying", + zap.String("message_id", message.ID), + zap.Int("attempt", attempt+1), + zap.Int("max_retries", maxRetries), + zap.Error(err)) + time.Sleep(retryDelay) + } + } + s.logger.Error("failed to create message with ID after retries", + zap.String("id", message.ID), + zap.Int("retries", maxRetries), + zap.Error(err)) + return err +} + +// GetMessage retrieves a message by ID +func (s *Service) GetMessage(ctx context.Context, id string) (*models.Message, error) { + return s.repo.GetMessage(ctx, id) +} + +// ListMessages returns all messages for a session. +func (s *Service) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { + return s.repo.ListMessages(ctx, sessionID) +} + +// ListMessagesPaginated returns messages for a session with pagination options. +func (s *Service) ListMessagesPaginated(ctx context.Context, req ListMessagesRequest) ([]*models.Message, bool, error) { + limit := req.Limit + if limit <= 0 && (req.Before != "" || req.After != "") { + limit = DefaultMessagesPageSize + } + if limit > MaxMessagesPageSize { + limit = MaxMessagesPageSize + } + return s.repo.ListMessagesPaginated(ctx, req.TaskSessionID, models.ListMessagesOptions{ + Limit: limit, + Before: req.Before, + After: req.After, + Sort: req.Sort, + }) +} + +// DeleteMessage deletes a message +func (s *Service) DeleteMessage(ctx context.Context, id string) error { + if err := s.repo.DeleteMessage(ctx, id); err != nil { + s.logger.Error("failed to delete message", zap.String("message_id", id), zap.Error(err)) + return err + } + + s.logger.Info("message deleted", zap.String("message_id", id)) + return nil +} + +// UpdateMessage updates an existing message and publishes an event. +func (s *Service) UpdateMessage(ctx context.Context, message *models.Message) error { + if err := s.repo.UpdateMessage(ctx, message); err != nil { + s.logger.Error("failed to update message", + zap.String("message_id", message.ID), + zap.Error(err)) + return err + } + + // Publish message.updated event for real-time streaming + s.publishMessageEvent(ctx, events.MessageUpdated, message) + + return nil +} + +// AppendMessageContent appends additional content to an existing message. +// This is used for streaming agent responses where content arrives incrementally. +func (s *Service) AppendMessageContent(ctx context.Context, messageID, additionalContent string) error { + message, err := s.repo.GetMessage(ctx, messageID) + if err != nil { + s.logger.Warn("message not found for append", + zap.String("message_id", messageID), + zap.Error(err)) + return err + } + + // Append the new content + message.Content += additionalContent + + if err := s.repo.UpdateMessage(ctx, message); err != nil { + s.logger.Error("failed to append message content", + zap.String("message_id", messageID), + zap.Error(err)) + return err + } + + // Publish message.updated event for real-time streaming + s.publishMessageEvent(ctx, events.MessageUpdated, message) + + s.logger.Debug("message content appended", + zap.String("message_id", messageID), + zap.Int("appended_length", len(additionalContent)), + zap.Int("total_length", len(message.Content))) + + return nil +} + +// AppendThinkingContent appends additional thinking content to an existing thinking message. +// This updates the metadata.thinking field for streaming agent reasoning. +func (s *Service) AppendThinkingContent(ctx context.Context, messageID, additionalContent string) error { + message, err := s.repo.GetMessage(ctx, messageID) + if err != nil { + s.logger.Warn("thinking message not found for append", + zap.String("message_id", messageID), + zap.Error(err)) + return err + } + + // Initialize metadata if nil + if message.Metadata == nil { + message.Metadata = make(map[string]interface{}) + } + + // Get existing thinking content and append + existingThinking := "" + if existing, ok := message.Metadata["thinking"].(string); ok { + existingThinking = existing + } + message.Metadata["thinking"] = existingThinking + additionalContent + + if err := s.repo.UpdateMessage(ctx, message); err != nil { + s.logger.Error("failed to append thinking content", + zap.String("message_id", messageID), + zap.Error(err)) + return err + } + + // Publish message.updated event for real-time streaming + s.publishMessageEvent(ctx, events.MessageUpdated, message) + + s.logger.Debug("thinking content appended", + zap.String("message_id", messageID), + zap.Int("appended_length", len(additionalContent))) + + return nil +} + +// UpdateToolCallMessage updates a tool call message's status, optionally title and normalized data. +// It includes retry logic to handle race conditions where the complete event +// may arrive before the message has been created by the start event. +// If the message is not found after retries and taskID/turnID/msgType are provided, it creates the message. +// The normalized parameter contains typed tool payload data that gets added to metadata. +func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result, title string, normalized *streams.NormalizedPayload) error { + return s.UpdateToolCallMessageWithCreate(ctx, sessionID, toolCallID, "", status, result, title, normalized, "", "", "") +} + +// UpdateToolCallMessageWithCreate is like UpdateToolCallMessage but can create the message if not found. +// If taskID, turnID, and msgType are provided, the message will be created if it doesn't exist. +// parentToolCallID is used for subagent nesting (empty for top-level). +func (s *Service) UpdateToolCallMessageWithCreate(ctx context.Context, sessionID, toolCallID, parentToolCallID, status, result, title string, normalized *streams.NormalizedPayload, taskID, turnID, msgType string) error { + const maxRetries = 5 + const retryDelay = 100 * time.Millisecond + + message, err := s.getToolCallMessageWithRetry(ctx, sessionID, toolCallID, maxRetries, retryDelay) + + // If message not found and we have enough info to create it, do so + if err != nil && taskID != "" && msgType != "" { + return s.createToolCallMessageFallback(ctx, sessionID, toolCallID, parentToolCallID, status, title, turnID, taskID, msgType, normalized) + } + + if err != nil { + s.logger.Warn("tool call message not found for update after retries", + zap.String("session_id", sessionID), + zap.String("tool_call_id", toolCallID), + zap.Int("retries", maxRetries), + zap.Error(err)) + return err + } + + s.applyToolCallMessageUpdate(message, status, result, title, normalized) + + if err := s.repo.UpdateMessage(ctx, message); err != nil { + s.logger.Error("failed to update tool call message", + zap.String("message_id", message.ID), + zap.String("tool_call_id", toolCallID), + zap.Error(err)) + return err + } + + // Publish message.updated event + s.publishMessageEvent(ctx, events.MessageUpdated, message) + + s.logger.Info("tool call message updated", + zap.String("message_id", message.ID), + zap.String("tool_call_id", toolCallID), + zap.String("status", status)) + + return nil +} + +// getToolCallMessageWithRetry fetches a tool call message with retry logic for race conditions. +func (s *Service) getToolCallMessageWithRetry(ctx context.Context, sessionID, toolCallID string, maxRetries int, retryDelay time.Duration) (*models.Message, error) { + var message *models.Message + var err error + for attempt := 0; attempt < maxRetries; attempt++ { + message, err = s.repo.GetMessageByToolCallID(ctx, sessionID, toolCallID) + if err == nil { + return message, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if attempt < maxRetries-1 { + s.logger.Debug("tool call message not found, retrying", + zap.String("session_id", sessionID), + zap.String("tool_call_id", toolCallID), + zap.Int("attempt", attempt+1), + zap.Int("max_retries", maxRetries)) + time.Sleep(retryDelay) + } + } + return nil, err +} + +// createToolCallMessageFallback creates a tool call message when it cannot be found via GetMessageByToolCallID. +func (s *Service) createToolCallMessageFallback(ctx context.Context, sessionID, toolCallID, parentToolCallID, status, title, turnID, taskID, msgType string, normalized *streams.NormalizedPayload) error { + s.logger.Info("tool call message not found, creating it", + zap.String("session_id", sessionID), + zap.String("tool_call_id", toolCallID), + zap.String("task_id", taskID), + zap.String("msg_type", msgType)) + + metadata := map[string]interface{}{ + "tool_call_id": toolCallID, + "title": title, + "status": status, + } + if parentToolCallID != "" { + metadata["parent_tool_call_id"] = parentToolCallID + } + if normalized != nil { + metadata["normalized"] = normalized + } + + msg, createErr := s.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + TurnID: turnID, + Content: title, + AuthorType: "agent", + Type: msgType, + Metadata: metadata, + }) + if createErr != nil { + s.logger.Error("failed to create tool call message as fallback", + zap.String("session_id", sessionID), + zap.String("tool_call_id", toolCallID), + zap.Error(createErr)) + return createErr + } + + s.logger.Info("created tool call message as fallback", + zap.String("message_id", msg.ID), + zap.String("tool_call_id", toolCallID), + zap.String("status", status)) + return nil +} + +// applyToolCallMessageUpdate applies status, result, normalized data, and title to a tool call message. +func (s *Service) applyToolCallMessageUpdate(message *models.Message, status, result, title string, normalized *streams.NormalizedPayload) { + if message.Metadata == nil { + message.Metadata = make(map[string]interface{}) + } + message.Metadata["status"] = status + if result != "" { + message.Metadata["result"] = result + } + + if normalized != nil { + message.Metadata["normalized"] = normalized + // Update message type if the normalized kind changed + // This handles cases like Read on a directory converting to code_search + newMsgType := models.MessageType(normalized.Kind().ToMessageType()) + if newMsgType != message.Type { + s.logger.Debug("updating message type based on normalized kind", + zap.String("message_id", message.ID), + zap.String("old_type", string(message.Type)), + zap.String("new_type", string(newMsgType)), + zap.String("normalized_kind", string(normalized.Kind()))) + message.Type = newMsgType + } + } + + // Update title/content if provided and different from current + if title != "" && title != message.Content { + message.Content = title + message.Metadata["title"] = title + } +} + +// UpdatePermissionMessage updates a permission request message's status. +// It includes retry logic to handle race conditions. +func (s *Service) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error { + const maxRetries = 5 + const retryDelay = 100 * time.Millisecond + + var message *models.Message + var err error + + // Retry loop to handle race condition + for attempt := 0; attempt < maxRetries; attempt++ { + message, err = s.repo.GetMessageByPendingID(ctx, sessionID, pendingID) + if err == nil { + break + } + + if ctx.Err() != nil { + return ctx.Err() + } + + if attempt < maxRetries-1 { + s.logger.Debug("permission message not found, retrying", + zap.String("session_id", sessionID), + zap.String("pending_id", pendingID), + zap.Int("attempt", attempt+1), + zap.Int("max_retries", maxRetries)) + time.Sleep(retryDelay) + } + } + + if err != nil { + s.logger.Warn("permission message not found for update after retries", + zap.String("session_id", sessionID), + zap.String("pending_id", pendingID), + zap.Int("retries", maxRetries), + zap.Error(err)) + return err + } + + if message.Metadata == nil { + message.Metadata = make(map[string]interface{}) + } + message.Metadata["status"] = status + + if err := s.repo.UpdateMessage(ctx, message); err != nil { + s.logger.Error("failed to update permission message", + zap.String("message_id", message.ID), + zap.String("pending_id", pendingID), + zap.Error(err)) + return err + } + + // Publish message.updated event + s.publishMessageEvent(ctx, events.MessageUpdated, message) + + // When a permission expires, also mark the related tool call as cancelled + // so the UI no longer shows a loading spinner on the tool call. + if status == "expired" { + if toolCallID, ok := message.Metadata["tool_call_id"].(string); ok && toolCallID != "" { + if err := s.UpdateToolCallMessage(ctx, sessionID, toolCallID, "error", "", "", nil); err != nil { + s.logger.Warn("failed to cancel related tool call message", + zap.String("tool_call_id", toolCallID), + zap.String("pending_id", pendingID), + zap.Error(err)) + } + } + } + + s.logger.Info("permission message updated", + zap.String("message_id", message.ID), + zap.String("pending_id", pendingID), + zap.String("status", status)) + + return nil +} + +// UpdateClarificationMessage updates a clarification request message's status and response. +// It includes retry logic to handle race conditions. +// The answers parameter should be a slice of answer objects with question_id, selected_options, and custom_text. +func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answers interface{}) error { + const maxRetries = 5 + const retryDelay = 100 * time.Millisecond + + var message *models.Message + var err error + + // Retry loop to handle race condition + for attempt := 0; attempt < maxRetries; attempt++ { + message, err = s.repo.GetMessageByPendingID(ctx, sessionID, pendingID) + if err == nil { + break + } + + if ctx.Err() != nil { + return ctx.Err() + } + + if attempt < maxRetries-1 { + s.logger.Debug("clarification message not found, retrying", + zap.String("session_id", sessionID), + zap.String("pending_id", pendingID), + zap.Int("attempt", attempt+1), + zap.Int("max_retries", maxRetries)) + time.Sleep(retryDelay) + } + } + + if err != nil { + s.logger.Warn("clarification message not found for update after retries", + zap.String("session_id", sessionID), + zap.String("pending_id", pendingID), + zap.Int("retries", maxRetries), + zap.Error(err)) + return err + } + + if message.Metadata == nil { + message.Metadata = make(map[string]interface{}) + } + message.Metadata["status"] = status + if answers != nil { + message.Metadata["response"] = answers + } + + if err := s.repo.UpdateMessage(ctx, message); err != nil { + s.logger.Error("failed to update clarification message", + zap.String("message_id", message.ID), + zap.String("pending_id", pendingID), + zap.Error(err)) + return err + } + + // Publish message.updated event + s.publishMessageEvent(ctx, events.MessageUpdated, message) + + s.logger.Info("clarification message updated", + zap.String("message_id", message.ID), + zap.String("pending_id", pendingID), + zap.String("status", status)) + + return nil +} diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go new file mode 100644 index 0000000000..93d66b162c --- /dev/null +++ b/apps/backend/internal/task/service/service_requests.go @@ -0,0 +1,187 @@ +package service + +import ( + v1 "github.com/kandev/kandev/pkg/api/v1" + + "github.com/kandev/kandev/internal/task/models" +) + +// Request types + +// TaskRepositoryInput for creating/updating task repositories +type TaskRepositoryInput struct { + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + LocalPath string `json:"local_path,omitempty"` + Name string `json:"name,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` +} + +// CreateTaskRequest contains the data for creating a new task +type CreateTaskRequest struct { + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []TaskRepositoryInput `json:"repositories,omitempty"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateTaskRequest contains the data for updating a task +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []TaskRepositoryInput `json:"repositories,omitempty"` + Position *int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CreateWorkflowRequest contains the data for creating a new workflow +type CreateWorkflowRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description"` + WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` +} + +// UpdateWorkflowRequest contains the data for updating a workflow +type UpdateWorkflowRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// CreateWorkspaceRequest contains the data for creating a new workspace +type CreateWorkspaceRequest struct { + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` + DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` + DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` +} + +// UpdateWorkspaceRequest contains the data for updating a workspace +type UpdateWorkspaceRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` + DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` + DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` +} + +// CreateRepositoryRequest contains the data for creating a new repository +type CreateRepositoryRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + WorktreeBranchPrefix string `json:"worktree_branch_prefix"` + PullBeforeWorktree *bool `json:"pull_before_worktree"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` +} + +// UpdateRepositoryRequest contains the data for updating a repository +type UpdateRepositoryRequest struct { + Name *string `json:"name,omitempty"` + SourceType *string `json:"source_type,omitempty"` + LocalPath *string `json:"local_path,omitempty"` + Provider *string `json:"provider,omitempty"` + ProviderRepoID *string `json:"provider_repo_id,omitempty"` + ProviderOwner *string `json:"provider_owner,omitempty"` + ProviderName *string `json:"provider_name,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` + PullBeforeWorktree *bool `json:"pull_before_worktree,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 +type CreateExecutorRequest struct { + Name string `json:"name"` + Type models.ExecutorType `json:"type"` + Status models.ExecutorStatus `json:"status"` + IsSystem bool `json:"is_system"` + Resumable bool `json:"resumable"` + Config map[string]string `json:"config,omitempty"` +} + +// UpdateExecutorRequest contains the data for updating an executor +type UpdateExecutorRequest struct { + Name *string `json:"name,omitempty"` + Type *models.ExecutorType `json:"type,omitempty"` + Status *models.ExecutorStatus `json:"status,omitempty"` + Resumable *bool `json:"resumable,omitempty"` + Config map[string]string `json:"config,omitempty"` +} + +// CreateEnvironmentRequest contains the data for creating an environment +type CreateEnvironmentRequest struct { + Name string `json:"name"` + Kind models.EnvironmentKind `json:"kind"` + WorktreeRoot string `json:"worktree_root,omitempty"` + ImageTag string `json:"image_tag,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` +} + +// UpdateEnvironmentRequest contains the data for updating an environment +type UpdateEnvironmentRequest struct { + Name *string `json:"name,omitempty"` + Kind *models.EnvironmentKind `json:"kind,omitempty"` + WorktreeRoot *string `json:"worktree_root,omitempty"` + ImageTag *string `json:"image_tag,omitempty"` + Dockerfile *string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` +} + +// ListMessagesRequest contains options for paginated message listing +type ListMessagesRequest struct { + TaskSessionID string + Limit int + Before string + After string + Sort string +} + +// CreateRepositoryScriptRequest contains the data for creating a repository script +type CreateRepositoryScriptRequest struct { + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + Command string `json:"command"` + Position int `json:"position"` +} + +// UpdateRepositoryScriptRequest contains the data for updating a repository script +type UpdateRepositoryScriptRequest struct { + Name *string `json:"name,omitempty"` + Command *string `json:"command,omitempty"` + Position *int `json:"position,omitempty"` +} + +// CreateMessageRequest contains the data for creating a new message +type CreateMessageRequest struct { + TaskSessionID string `json:"session_id"` + TaskID string `json:"task_id,omitempty"` + TurnID string `json:"turn_id"` + Content string `json:"content"` + AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" + AuthorID string `json:"author_id,omitempty"` + RequestsInput bool `json:"requests_input,omitempty"` + Type string `json:"type,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go new file mode 100644 index 0000000000..0abe88ff77 --- /dev/null +++ b/apps/backend/internal/task/service/service_resources.go @@ -0,0 +1,606 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +// Workspace operations + +// CreateWorkspace creates a new workspace +func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceRequest) (*models.Workspace, error) { + workspace := &models.Workspace{ + ID: uuid.New().String(), + Name: req.Name, + Description: req.Description, + OwnerID: req.OwnerID, + DefaultExecutorID: normalizeOptionalID(req.DefaultExecutorID), + DefaultEnvironmentID: normalizeOptionalID(req.DefaultEnvironmentID), + DefaultAgentProfileID: normalizeOptionalID(req.DefaultAgentProfileID), + } + + if err := s.repo.CreateWorkspace(ctx, workspace); err != nil { + s.logger.Error("failed to create workspace", zap.Error(err)) + return nil, err + } + + s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace) + s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name)) + return workspace, nil +} + +// GetWorkspace retrieves a workspace by ID +func (s *Service) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) { + return s.repo.GetWorkspace(ctx, id) +} + +// UpdateWorkspace updates an existing workspace +func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWorkspaceRequest) (*models.Workspace, error) { + workspace, err := s.repo.GetWorkspace(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + workspace.Name = *req.Name + } + if req.Description != nil { + workspace.Description = *req.Description + } + if req.DefaultExecutorID != nil { + workspace.DefaultExecutorID = normalizeOptionalID(req.DefaultExecutorID) + } + if req.DefaultEnvironmentID != nil { + workspace.DefaultEnvironmentID = normalizeOptionalID(req.DefaultEnvironmentID) + } + if req.DefaultAgentProfileID != nil { + workspace.DefaultAgentProfileID = normalizeOptionalID(req.DefaultAgentProfileID) + } + workspace.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateWorkspace(ctx, workspace); err != nil { + s.logger.Error("failed to update workspace", zap.String("workspace_id", id), zap.Error(err)) + return nil, err + } + + s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace) + s.logger.Info("workspace updated", zap.String("workspace_id", workspace.ID)) + return workspace, nil +} + +// DeleteWorkspace deletes a workspace +func (s *Service) DeleteWorkspace(ctx context.Context, id string) error { + workspace, err := s.repo.GetWorkspace(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteWorkspace(ctx, id); err != nil { + s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err)) + return err + } + s.publishWorkspaceEvent(ctx, events.WorkspaceDeleted, workspace) + s.logger.Info("workspace deleted", zap.String("workspace_id", id)) + return nil +} + +func normalizeOptionalID(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +// ListWorkspaces returns all workspaces +func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { + return s.repo.ListWorkspaces(ctx) +} + +// Workflow operations + +// CreateWorkflow creates a new workflow +func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) (*models.Workflow, error) { + workflow := &models.Workflow{ + ID: uuid.New().String(), + WorkspaceID: req.WorkspaceID, + Name: req.Name, + Description: req.Description, + WorkflowTemplateID: req.WorkflowTemplateID, + } + + if err := s.repo.CreateWorkflow(ctx, workflow); err != nil { + s.logger.Error("failed to create workflow", zap.Error(err)) + return nil, err + } + + // Create workflow steps from template if specified + if req.WorkflowTemplateID != nil && *req.WorkflowTemplateID != "" && s.workflowStepCreator != nil { + if err := s.workflowStepCreator.CreateStepsFromTemplate(ctx, workflow.ID, *req.WorkflowTemplateID); err != nil { + s.logger.Error("failed to create workflow steps from template", + zap.String("workflow_id", workflow.ID), + zap.String("template_id", *req.WorkflowTemplateID), + zap.Error(err)) + // Don't fail workflow creation, just log the error + } + } + + s.publishWorkflowEvent(ctx, events.WorkflowCreated, workflow) + s.logger.Info("workflow created", zap.String("workflow_id", workflow.ID), zap.String("name", workflow.Name)) + return workflow, nil +} + +// GetWorkflow retrieves a workflow by ID +func (s *Service) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { + return s.repo.GetWorkflow(ctx, id) +} + +// UpdateWorkflow updates an existing workflow +func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWorkflowRequest) (*models.Workflow, error) { + workflow, err := s.repo.GetWorkflow(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + workflow.Name = *req.Name + } + if req.Description != nil { + workflow.Description = *req.Description + } + workflow.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateWorkflow(ctx, workflow); err != nil { + s.logger.Error("failed to update workflow", zap.String("workflow_id", id), zap.Error(err)) + return nil, err + } + + s.publishWorkflowEvent(ctx, events.WorkflowUpdated, workflow) + s.logger.Info("workflow updated", zap.String("workflow_id", workflow.ID)) + return workflow, nil +} + +// DeleteWorkflow deletes a workflow +func (s *Service) DeleteWorkflow(ctx context.Context, id string) error { + workflow, err := s.repo.GetWorkflow(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteWorkflow(ctx, id); err != nil { + s.logger.Error("failed to delete workflow", zap.String("workflow_id", id), zap.Error(err)) + return err + } + + s.publishWorkflowEvent(ctx, events.WorkflowDeleted, workflow) + s.logger.Info("workflow deleted", zap.String("workflow_id", id)) + return nil +} + +// ListWorkflows returns all workflows for a workspace (or all if empty) +func (s *Service) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { + return s.repo.ListWorkflows(ctx, workspaceID) +} + +// Repository operations + +func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryRequest) (*models.Repository, error) { + sourceType := req.SourceType + if sourceType == "" { + sourceType = "local" + } + prefix := strings.TrimSpace(req.WorktreeBranchPrefix) + if err := worktree.ValidateBranchPrefix(prefix); err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) + } + if prefix == "" { + prefix = worktree.DefaultBranchPrefix + } + pullBeforeWorktree := true + if req.PullBeforeWorktree != nil { + pullBeforeWorktree = *req.PullBeforeWorktree + } + repository := &models.Repository{ + ID: uuid.New().String(), + WorkspaceID: req.WorkspaceID, + Name: req.Name, + SourceType: sourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + WorktreeBranchPrefix: prefix, + PullBeforeWorktree: pullBeforeWorktree, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + DevScript: req.DevScript, + } + + if err := s.repo.CreateRepository(ctx, repository); err != nil { + s.logger.Error("failed to create repository", zap.Error(err)) + return nil, err + } + + s.publishRepositoryEvent(ctx, events.RepositoryCreated, repository) + s.logger.Info("repository created", zap.String("repository_id", repository.ID)) + return repository, nil +} + +func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) { + return s.repo.GetRepository(ctx, id) +} + +func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRepositoryRequest) (*models.Repository, error) { + repository, err := s.repo.GetRepository(ctx, id) + if err != nil { + return nil, err + } + if err := applyRepositoryUpdates(repository, req); err != nil { + return nil, err + } + repository.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateRepository(ctx, repository); err != nil { + s.logger.Error("failed to update repository", zap.String("repository_id", id), zap.Error(err)) + return nil, err + } + + s.publishRepositoryEvent(ctx, events.RepositoryUpdated, repository) + s.logger.Info("repository updated", zap.String("repository_id", repository.ID)) + return repository, nil +} + +// applyRepositoryUpdates applies the non-nil fields from req onto repository. +// Returns an error if the WorktreeBranchPrefix is invalid. +func applyRepositoryUpdates(repository *models.Repository, req *UpdateRepositoryRequest) error { + if req.Name != nil { + repository.Name = *req.Name + } + if req.SourceType != nil { + repository.SourceType = *req.SourceType + } + if req.LocalPath != nil { + repository.LocalPath = *req.LocalPath + } + if req.Provider != nil { + repository.Provider = *req.Provider + } + if req.ProviderRepoID != nil { + repository.ProviderRepoID = *req.ProviderRepoID + } + if req.ProviderOwner != nil { + repository.ProviderOwner = *req.ProviderOwner + } + if req.ProviderName != nil { + repository.ProviderName = *req.ProviderName + } + if req.DefaultBranch != nil { + repository.DefaultBranch = *req.DefaultBranch + } + if req.WorktreeBranchPrefix != nil { + prefix := strings.TrimSpace(*req.WorktreeBranchPrefix) + if err := worktree.ValidateBranchPrefix(prefix); err != nil { + return fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) + } + repository.WorktreeBranchPrefix = prefix + } + if req.PullBeforeWorktree != nil { + repository.PullBeforeWorktree = *req.PullBeforeWorktree + } + if req.SetupScript != nil { + repository.SetupScript = *req.SetupScript + } + if req.CleanupScript != nil { + repository.CleanupScript = *req.CleanupScript + } + if req.DevScript != nil { + repository.DevScript = *req.DevScript + } + return nil +} + +func (s *Service) DeleteRepository(ctx context.Context, id string) error { + repository, err := s.repo.GetRepository(ctx, id) + if err != nil { + return err + } + active, err := s.repo.HasActiveTaskSessionsByRepository(ctx, id) + if err != nil { + s.logger.Error("failed to check active agent sessions for repository", zap.String("repository_id", id), zap.Error(err)) + return err + } + if active { + return ErrActiveTaskSessions + } + if err := s.repo.DeleteRepository(ctx, id); err != nil { + s.logger.Error("failed to delete repository", zap.String("repository_id", id), zap.Error(err)) + return err + } + s.publishRepositoryEvent(ctx, events.RepositoryDeleted, repository) + s.logger.Info("repository deleted", zap.String("repository_id", id)) + return nil +} + +func (s *Service) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { + return s.repo.ListRepositories(ctx, workspaceID) +} + +// Repository script operations + +func (s *Service) CreateRepositoryScript(ctx context.Context, req *CreateRepositoryScriptRequest) (*models.RepositoryScript, error) { + script := &models.RepositoryScript{ + ID: uuid.New().String(), + RepositoryID: req.RepositoryID, + Name: req.Name, + Command: req.Command, + Position: req.Position, + } + if err := s.repo.CreateRepositoryScript(ctx, script); err != nil { + s.logger.Error("failed to create repository script", zap.Error(err)) + return nil, err + } + s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptCreated, script) + s.logger.Info("repository script created", zap.String("script_id", script.ID)) + return script, nil +} + +func (s *Service) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { + return s.repo.GetRepositoryScript(ctx, id) +} + +func (s *Service) UpdateRepositoryScript(ctx context.Context, id string, req *UpdateRepositoryScriptRequest) (*models.RepositoryScript, error) { + script, err := s.repo.GetRepositoryScript(ctx, id) + if err != nil { + return nil, err + } + if req.Name != nil { + script.Name = *req.Name + } + if req.Command != nil { + script.Command = *req.Command + } + if req.Position != nil { + script.Position = *req.Position + } + script.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateRepositoryScript(ctx, script); err != nil { + s.logger.Error("failed to update repository script", zap.String("script_id", id), zap.Error(err)) + return nil, err + } + s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptUpdated, script) + s.logger.Info("repository script updated", zap.String("script_id", script.ID)) + return script, nil +} + +func (s *Service) DeleteRepositoryScript(ctx context.Context, id string) error { + script, err := s.repo.GetRepositoryScript(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteRepositoryScript(ctx, id); err != nil { + s.logger.Error("failed to delete repository script", zap.String("script_id", id), zap.Error(err)) + return err + } + s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptDeleted, script) + s.logger.Info("repository script deleted", zap.String("script_id", id)) + return nil +} + +func (s *Service) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { + return s.repo.ListRepositoryScripts(ctx, repositoryID) +} + +// Executor operations + +func (s *Service) CreateExecutor(ctx context.Context, req *CreateExecutorRequest) (*models.Executor, error) { + if err := validateExecutorConfig(req.Config); err != nil { + return nil, err + } + executor := &models.Executor{ + ID: uuid.New().String(), + Name: req.Name, + Type: req.Type, + Status: req.Status, + IsSystem: req.IsSystem, + Resumable: req.Resumable, + Config: req.Config, + } + + if err := s.repo.CreateExecutor(ctx, executor); err != nil { + return nil, err + } + s.publishExecutorEvent(ctx, events.ExecutorCreated, executor) + return executor, nil +} + +func (s *Service) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { + return s.repo.GetExecutor(ctx, id) +} + +func (s *Service) UpdateExecutor(ctx context.Context, id string, req *UpdateExecutorRequest) (*models.Executor, error) { + executor, err := s.repo.GetExecutor(ctx, id) + if err != nil { + return nil, err + } + if err := validateExecutorUpdateRequest(executor, req); err != nil { + return nil, err + } + applyExecutorUpdates(executor, req) + executor.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateExecutor(ctx, executor); err != nil { + return nil, err + } + s.publishExecutorEvent(ctx, events.ExecutorUpdated, executor) + return executor, nil +} + +// validateExecutorUpdateRequest validates config and system executor constraints. +func validateExecutorUpdateRequest(executor *models.Executor, req *UpdateExecutorRequest) error { + if req.Config != nil { + if err := validateExecutorConfig(req.Config); err != nil { + return err + } + } + if !executor.IsSystem { + return nil + } + if req.Name != nil && *req.Name != executor.Name { + return fmt.Errorf("system executors cannot be modified") + } + if req.Type != nil && *req.Type != executor.Type { + return fmt.Errorf("system executors cannot be modified") + } + if req.Status != nil && *req.Status != executor.Status { + return fmt.Errorf("system executors cannot be modified") + } + if req.Resumable != nil && *req.Resumable != executor.Resumable { + return fmt.Errorf("system executors cannot be modified") + } + return nil +} + +// applyExecutorUpdates copies non-nil request fields onto the executor model. +func applyExecutorUpdates(executor *models.Executor, req *UpdateExecutorRequest) { + if req.Name != nil { + executor.Name = *req.Name + } + if req.Type != nil { + executor.Type = *req.Type + } + if req.Status != nil { + executor.Status = *req.Status + } + if req.Resumable != nil { + executor.Resumable = *req.Resumable + } + if req.Config != nil { + executor.Config = req.Config + } +} + +func (s *Service) DeleteExecutor(ctx context.Context, id string) error { + executor, err := s.repo.GetExecutor(ctx, id) + if err != nil { + return err + } + if executor.IsSystem { + return fmt.Errorf("system executors cannot be deleted") + } + active, err := s.repo.HasActiveTaskSessionsByExecutor(ctx, id) + if err != nil { + s.logger.Error("failed to check active agent sessions for executor", zap.String("executor_id", id), zap.Error(err)) + return err + } + if active { + return ErrActiveTaskSessions + } + if err := s.repo.DeleteExecutor(ctx, id); err != nil { + return err + } + s.publishExecutorEvent(ctx, events.ExecutorDeleted, executor) + return nil +} + +func (s *Service) ListExecutors(ctx context.Context) ([]*models.Executor, error) { + return s.repo.ListExecutors(ctx) +} + +// Environment operations + +func (s *Service) CreateEnvironment(ctx context.Context, req *CreateEnvironmentRequest) (*models.Environment, error) { + environment := &models.Environment{ + ID: uuid.New().String(), + Name: req.Name, + Kind: req.Kind, + IsSystem: false, + WorktreeRoot: req.WorktreeRoot, + ImageTag: req.ImageTag, + Dockerfile: req.Dockerfile, + BuildConfig: req.BuildConfig, + } + if err := s.repo.CreateEnvironment(ctx, environment); err != nil { + return nil, err + } + s.publishEnvironmentEvent(ctx, events.EnvironmentCreated, environment) + return environment, nil +} + +func (s *Service) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { + return s.repo.GetEnvironment(ctx, id) +} + +func (s *Service) UpdateEnvironment(ctx context.Context, id string, req *UpdateEnvironmentRequest) (*models.Environment, error) { + environment, err := s.repo.GetEnvironment(ctx, id) + if err != nil { + return nil, err + } + if environment.IsSystem { + if req.Name != nil || req.Kind != nil || req.ImageTag != nil || req.Dockerfile != nil || req.BuildConfig != nil { + return nil, fmt.Errorf("system environments can only update the worktree root") + } + } + if req.Name != nil { + environment.Name = *req.Name + } + if req.Kind != nil { + environment.Kind = *req.Kind + } + if req.WorktreeRoot != nil { + environment.WorktreeRoot = *req.WorktreeRoot + } + if req.ImageTag != nil { + environment.ImageTag = *req.ImageTag + } + if req.Dockerfile != nil { + environment.Dockerfile = *req.Dockerfile + } + if req.BuildConfig != nil { + environment.BuildConfig = req.BuildConfig + } + environment.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateEnvironment(ctx, environment); err != nil { + return nil, err + } + s.publishEnvironmentEvent(ctx, events.EnvironmentUpdated, environment) + return environment, nil +} + +func (s *Service) DeleteEnvironment(ctx context.Context, id string) error { + environment, err := s.repo.GetEnvironment(ctx, id) + if err != nil { + return err + } + if environment.IsSystem { + return fmt.Errorf("system environments cannot be deleted") + } + active, err := s.repo.HasActiveTaskSessionsByEnvironment(ctx, id) + if err != nil { + s.logger.Error("failed to check active agent sessions for environment", zap.String("environment_id", id), zap.Error(err)) + return err + } + if active { + return ErrActiveTaskSessions + } + if err := s.repo.DeleteEnvironment(ctx, id); err != nil { + return err + } + s.publishEnvironmentEvent(ctx, events.EnvironmentDeleted, environment) + return nil +} + +func (s *Service) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { + return s.repo.ListEnvironments(ctx) +} diff --git a/apps/backend/internal/task/service/service_sessions.go b/apps/backend/internal/task/service/service_sessions.go new file mode 100644 index 0000000000..0af025bb48 --- /dev/null +++ b/apps/backend/internal/task/service/service_sessions.go @@ -0,0 +1,76 @@ +package service + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/models" +) + +// ListTaskSessions returns all sessions for a task. +func (s *Service) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + return s.repo.ListTaskSessions(ctx, taskID) +} + +// GetTaskSession returns a single session by ID. +func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models.TaskSession, error) { + return s.repo.GetTaskSession(ctx, sessionID) +} + +// GetPrimarySession returns the primary session for a task. +func (s *Service) GetPrimarySession(ctx context.Context, taskID string) (*models.TaskSession, error) { + return s.repo.GetPrimarySessionByTaskID(ctx, taskID) +} + +// GetPrimarySessionIDsForTasks returns a map of task ID to primary session ID for the given task IDs. +// Tasks without a primary session are not included in the result. +func (s *Service) GetPrimarySessionIDsForTasks(ctx context.Context, taskIDs []string) (map[string]string, error) { + return s.repo.GetPrimarySessionIDsByTaskIDs(ctx, taskIDs) +} + +// GetSessionCountsForTasks returns a map of task ID to session count for the given task IDs. +func (s *Service) GetSessionCountsForTasks(ctx context.Context, taskIDs []string) (map[string]int, error) { + return s.repo.GetSessionCountsByTaskIDs(ctx, taskIDs) +} + +// GetPrimarySessionInfoForTasks returns a map of task ID to primary session info for the given task IDs. +func (s *Service) GetPrimarySessionInfoForTasks(ctx context.Context, taskIDs []string) (map[string]*models.TaskSession, error) { + return s.repo.GetPrimarySessionInfoByTaskIDs(ctx, taskIDs) +} + +// SetPrimarySession sets a session as the primary session for its task. +// This will unset any existing primary session for the same task. +func (s *Service) SetPrimarySession(ctx context.Context, sessionID string) error { + if err := s.repo.SetSessionPrimary(ctx, sessionID); err != nil { + s.logger.Error("failed to set primary session", + zap.String("session_id", sessionID), + zap.Error(err)) + return err + } + return nil +} + +// MoveSessionToStep moves a session to a different workflow step. +func (s *Service) MoveSessionToStep(ctx context.Context, sessionID string, stepID string) error { + if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, stepID); err != nil { + s.logger.Error("failed to move session to step", + zap.String("session_id", sessionID), + zap.String("step_id", stepID), + zap.Error(err)) + return err + } + return nil +} + +// UpdateSessionReviewStatus updates the review status of a session. +func (s *Service) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { + if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, status); err != nil { + s.logger.Error("failed to update session review status", + zap.String("session_id", sessionID), + zap.String("status", status), + zap.Error(err)) + return err + } + return nil +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go new file mode 100644 index 0000000000..4003951416 --- /dev/null +++ b/apps/backend/internal/task/service/service_tasks.go @@ -0,0 +1,527 @@ +package service + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +// Task operations + +// CreateTask creates a new task and publishes a task.created event +func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) { + // Auto-resolve start step if not provided + workflowStepID := req.WorkflowStepID + if workflowStepID == "" && req.WorkflowID != "" && s.startStepResolver != nil { + resolvedID, err := s.startStepResolver.ResolveStartStep(ctx, req.WorkflowID) + if err != nil { + s.logger.Warn("failed to resolve start step, using empty", + zap.String("workflow_id", req.WorkflowID), + zap.Error(err)) + } else { + workflowStepID = resolvedID + } + } + + state := v1.TaskStateCreated + if req.State != nil { + state = *req.State + } + task := &models.Task{ + ID: uuid.New().String(), + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: workflowStepID, + Title: req.Title, + Description: req.Description, + State: state, + Priority: req.Priority, + Position: req.Position, + Metadata: req.Metadata, + } + + if err := s.repo.CreateTask(ctx, task); err != nil { + s.logger.Error("failed to create task", zap.Error(err)) + return nil, err + } + + if err := s.createTaskRepositories(ctx, task.ID, req.WorkspaceID, req.Repositories); err != nil { + return nil, err + } + + // Load repositories into task for response + repos, err := s.repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + s.logger.Error("failed to list task repositories", zap.Error(err)) + } else { + task.Repositories = repos + } + + s.publishTaskEvent(ctx, events.TaskCreated, task, nil) + s.logger.Info("task created", zap.String("task_id", task.ID), zap.String("title", task.Title)) + + return task, nil +} + +// createTaskRepositories creates task-repository associations, resolving local paths to repository IDs. +func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error { + var repoByPath map[string]*models.Repository + for _, repoInput := range repositories { + if repoInput.RepositoryID == "" && repoInput.LocalPath != "" { + repos, err := s.repo.ListRepositories(ctx, workspaceID) + if err != nil { + s.logger.Error("failed to list repositories", zap.Error(err)) + return err + } + repoByPath = make(map[string]*models.Repository, len(repos)) + for _, repo := range repos { + if repo.LocalPath == "" { + continue + } + repoByPath[repo.LocalPath] = repo + } + break + } + } + + for i, repoInput := range repositories { + repositoryID, baseBranch, err := s.resolveRepoInput(ctx, workspaceID, repoInput, repoByPath) + if err != nil { + return err + } + if repositoryID == "" { + return fmt.Errorf("repository_id is required") + } + taskRepo := &models.TaskRepository{ + TaskID: taskID, + RepositoryID: repositoryID, + BaseBranch: baseBranch, + Position: i, + Metadata: make(map[string]interface{}), + } + if err := s.repo.CreateTaskRepository(ctx, taskRepo); err != nil { + s.logger.Error("failed to create task repository", zap.Error(err)) + return err + } + } + return nil +} + +// resolveRepoInput resolves a RepositoryInput to a repositoryID and baseBranch, +// creating the repository if it doesn't exist yet. +func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repoInput TaskRepositoryInput, repoByPath map[string]*models.Repository) (repositoryID, baseBranch string, err error) { + repositoryID = repoInput.RepositoryID + baseBranch = repoInput.BaseBranch + if repositoryID != "" || repoInput.LocalPath == "" { + return repositoryID, baseBranch, nil + } + repo := repoByPath[repoInput.LocalPath] + if repo == nil { + name := strings.TrimSpace(repoInput.Name) + if name == "" { + name = filepath.Base(repoInput.LocalPath) + } + defaultBranch := repoInput.DefaultBranch + if defaultBranch == "" { + defaultBranch = repoInput.BaseBranch + } + created, createErr := s.CreateRepository(ctx, &CreateRepositoryRequest{ + WorkspaceID: workspaceID, + Name: name, + SourceType: "local", + LocalPath: repoInput.LocalPath, + DefaultBranch: defaultBranch, + }) + if createErr != nil { + return "", "", createErr + } + repo = created + if repoByPath != nil { + repoByPath[repoInput.LocalPath] = repo + } + } + repositoryID = repo.ID + if baseBranch == "" { + baseBranch = repo.DefaultBranch + } + return repositoryID, baseBranch, nil +} + +// replaceTaskRepositories deletes all existing task-repository associations and recreates them. +func (s *Service) replaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error { + if err := s.repo.DeleteTaskRepositoriesByTask(ctx, taskID); err != nil { + s.logger.Error("failed to delete task repositories", zap.Error(err)) + return err + } + return s.createTaskRepositories(ctx, taskID, workspaceID, repositories) +} + +// GetTask retrieves a task by ID and populates repositories +func (s *Service) GetTask(ctx context.Context, id string) (*models.Task, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + // Load task repositories + repos, err := s.repo.ListTaskRepositories(ctx, id) + if err != nil { + s.logger.Error("failed to list task repositories", zap.Error(err)) + } else { + task.Repositories = repos + } + + return task, nil +} + +// UpdateTask updates an existing task and publishes a task.updated event +func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequest) (*models.Task, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + var oldState *v1.TaskState + stateChanged := false + + if req.Title != nil { + task.Title = *req.Title + } + if req.Description != nil { + task.Description = *req.Description + } + if req.Priority != nil { + task.Priority = *req.Priority + } + if req.State != nil && task.State != *req.State { + current := task.State + oldState = ¤t + task.State = *req.State + stateChanged = true + } + if req.Position != nil { + task.Position = *req.Position + } + if req.Metadata != nil { + task.Metadata = req.Metadata + } + task.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to update task", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + // Update task repositories if provided + if req.Repositories != nil { + if err := s.replaceTaskRepositories(ctx, task.ID, task.WorkspaceID, req.Repositories); err != nil { + return nil, err + } + } + + // Load repositories into task for response + repos, err := s.repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + s.logger.Error("failed to list task repositories", zap.Error(err)) + } else { + task.Repositories = repos + } + + if stateChanged && oldState != nil { + s.publishTaskEvent(ctx, events.TaskStateChanged, task, oldState) + } + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + s.logger.Info("task updated", zap.String("task_id", task.ID)) + + return task, nil +} + +// ArchiveTask archives a task by setting its archived_at timestamp. +// The task remains in the DB but is excluded from active board views. +// Active agent sessions are stopped and worktrees cleaned up in background. +func (s *Service) ArchiveTask(ctx context.Context, id string) error { + start := time.Now() + + // 1. Get task and verify it exists + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return err + } + + if task.ArchivedAt != nil { + return fmt.Errorf("task is already archived: %s", id) + } + + // 2. Gather data needed for cleanup BEFORE archive + var activeSessionIDs []string + if s.executionStopper != nil { + activeSessions, err := s.repo.ListActiveTaskSessionsByTaskID(ctx, id) + if err != nil { + s.logger.Warn("failed to list active sessions for archive", + zap.String("task_id", id), + zap.Error(err)) + } + for _, sess := range activeSessions { + activeSessionIDs = append(activeSessionIDs, sess.ID) + } + } + + sessions, err := s.repo.ListTaskSessions(ctx, id) + if err != nil { + s.logger.Warn("failed to list task sessions for archive", + zap.String("task_id", id), + zap.Error(err)) + } + + var worktrees []*worktree.Worktree + if s.worktreeCleanup != nil { + if provider, ok := s.worktreeCleanup.(WorktreeProvider); ok { + worktrees, err = provider.GetAllByTaskID(ctx, id) + if err != nil { + s.logger.Warn("failed to list worktrees for archive", + zap.String("task_id", id), + zap.Error(err)) + } + } + } + + // 3. Set archived_at in DB + if err := s.repo.ArchiveTask(ctx, id); err != nil { + return err + } + + // 4. Re-read task for updated archived_at field + task, err = s.repo.GetTask(ctx, id) + if err != nil { + return err + } + + // 5. Publish task.updated event so frontend removes from board + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + s.logger.Info("task archived", + zap.String("task_id", id), + zap.Duration("duration", time.Since(start))) + + // 6. Background: Stop agents and cleanup worktrees + if len(activeSessionIDs) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 { + s.runAsyncTaskCleanup(id, sessions, worktrees, activeSessionIDs, + "task archived", "failed to stop session on task archive", "task archive cleanup completed") + } + + return nil +} + +// DeleteTask deletes a task and publishes a task.deleted event. +// For fast UI response, the DB delete and event publish happen synchronously, +// while agent stopping and worktree cleanup happen asynchronously. +func (s *Service) DeleteTask(ctx context.Context, id string) error { + start := time.Now() + + // 1. Get task (sync, fast) + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return err + } + + // 2. Gather data needed for cleanup BEFORE delete (sync, fast) + 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)) + } + + worktrees := s.gatherWorktreesForDelete(ctx, id) + + // 3. Get active session IDs for stopping agents (sync, fast) + // Must query before delete since DB records will be gone + var activeSessionIDs []string + if s.executionStopper != nil { + activeSessions, err := s.repo.ListActiveTaskSessionsByTaskID(ctx, id) + if err != nil { + s.logger.Warn("failed to list active sessions for delete", + zap.String("task_id", id), + zap.Error(err)) + } + for _, sess := range activeSessions { + activeSessionIDs = append(activeSessionIDs, sess.ID) + } + } + + // 4. Delete from DB (sync, fast) + if err := s.repo.DeleteTask(ctx, id); err != nil { + s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err)) + return err + } + + // 5. Publish event (sync, fast) - frontend removes task immediately + s.publishTaskEvent(ctx, events.TaskDeleted, task, nil) + s.logger.Info("task deleted", + zap.String("task_id", id), + zap.Duration("duration", time.Since(start))) + + // 6. Return immediately - all remaining cleanup is async + + // 7. Background: Stop agents and cleanup worktrees + if len(activeSessionIDs) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 { + s.runAsyncTaskCleanup(id, sessions, worktrees, activeSessionIDs, + "task deleted", "failed to stop session on task delete", "task cleanup completed") + } + + return nil +} + +// gatherWorktreesForDelete collects worktrees for a task before it is deleted. +// For legacy WorktreeCleanup implementations that do not implement WorktreeProvider, +// it triggers cleanup immediately and returns nil. +func (s *Service) gatherWorktreesForDelete(ctx context.Context, taskID string) []*worktree.Worktree { + if s.worktreeCleanup == nil { + return nil + } + provider, ok := s.worktreeCleanup.(WorktreeProvider) + if !ok { + // Fallback for legacy implementations: cleanup before delete. + if err := s.worktreeCleanup.OnTaskDeleted(ctx, taskID); err != nil { + s.logger.Warn("failed to cleanup worktree on task deletion", + zap.String("task_id", taskID), + zap.Error(err)) + } + return nil + } + worktrees, err := provider.GetAllByTaskID(ctx, taskID) + if err != nil { + s.logger.Warn("failed to list worktrees for delete", + zap.String("task_id", taskID), + zap.Error(err)) + } + return worktrees +} + +func (s *Service) runAsyncTaskCleanup( + id string, + sessions []*models.TaskSession, + worktrees []*worktree.Worktree, + activeSessionIDs []string, + stopReason, stopFailMsg, cleanupMsg string, +) { + go func() { + cleanupStart := time.Now() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if s.executionStopper != nil && len(activeSessionIDs) > 0 { + for _, sessionID := range activeSessionIDs { + if err := s.executionStopper.StopSession(cleanupCtx, sessionID, stopReason, true); err != nil { + s.logger.Warn(stopFailMsg, + zap.String("task_id", id), + zap.String("session_id", sessionID), + zap.Error(err)) + } + } + } + + cleanupErrors := s.performTaskCleanup(cleanupCtx, id, sessions, worktrees) + + if len(cleanupErrors) > 0 { + s.logger.Warn(cleanupMsg+" with errors", + zap.String("task_id", id), + zap.Int("error_count", len(cleanupErrors)), + zap.Duration("duration", time.Since(cleanupStart))) + } else { + s.logger.Info(cleanupMsg, + zap.String("task_id", id), + zap.Duration("duration", time.Since(cleanupStart))) + } + }() +} + +// performTaskCleanup handles post-deletion cleanup operations. +// Handles worktree cleanup and executor_running records. +// Agent stopping is handled separately in the DeleteTask background goroutine. +// Returns a slice of errors encountered (empty if all succeeded). +func (s *Service) performTaskCleanup( + ctx context.Context, + taskID string, + sessions []*models.TaskSession, + worktrees []*worktree.Worktree, +) []error { + var errs []error + + // Cleanup worktrees + if len(worktrees) > 0 { + if cleaner, ok := s.worktreeCleanup.(WorktreeBatchCleaner); ok { + if err := cleaner.CleanupWorktrees(ctx, worktrees); err != nil { + s.logger.Warn("failed to cleanup worktrees after delete", + zap.String("task_id", taskID), + zap.Error(err)) + errs = append(errs, fmt.Errorf("cleanup worktrees: %w", err)) + } + } + } + + // Delete executor running records for sessions + 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", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + // Don't add to errs - this is a debug-level issue + } + } + + return errs +} + +// ListTasks returns all tasks for a workflow +func (s *Service) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { + tasks, err := s.repo.ListTasks(ctx, workflowID) + if err != nil { + return nil, err + } + + // Load repositories for each task + for _, task := range tasks { + repos, err := s.repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + s.logger.Error("failed to list task repositories", zap.String("task_id", task.ID), zap.Error(err)) + } else { + task.Repositories = repos + } + } + + return tasks, nil +} + +// ListTasksByWorkspace returns paginated tasks for a workspace with task repositories loaded. +// If query is non-empty, filters by task title, description, repository name, or repository path. +func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int, includeArchived bool) ([]*models.Task, int, error) { + tasks, total, err := s.repo.ListTasksByWorkspace(ctx, workspaceID, query, page, pageSize, includeArchived) + if err != nil { + return nil, 0, err + } + + // Load repositories for each task + for _, task := range tasks { + repos, err := s.repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + s.logger.Error("failed to list task repositories", zap.String("task_id", task.ID), zap.Error(err)) + } else { + task.Repositories = repos + } + } + + return tasks, total, nil +} diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index e1a66be874..2421b3599d 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -83,7 +83,7 @@ func createTestService(t *testing.T) (*Service, *MockEventBus, repository.Reposi if err != nil { t.Fatalf("failed to create test repository: %v", err) } - repo := repository.Repository(repoImpl) + repo := repoImpl if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { t.Fatalf("failed to init worktree store: %v", err) } diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go new file mode 100644 index 0000000000..1cdcd2cabb --- /dev/null +++ b/apps/backend/internal/task/service/service_turns.go @@ -0,0 +1,242 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" +) + +// Turn operations + +// StartTurn creates a new turn for a session and publishes the turn.started event. +// Returns the created turn. +func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get session: %w", err) + } + + turn := &models.Turn{ + ID: uuid.New().String(), + TaskSessionID: sessionID, + TaskID: session.TaskID, + StartedAt: time.Now().UTC(), + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + if err := s.repo.CreateTurn(ctx, turn); err != nil { + s.logger.Error("failed to create turn", zap.Error(err)) + return nil, err + } + + s.publishTurnEvent(events.TurnStarted, turn) + + s.logger.Debug("started turn", + zap.String("turn_id", turn.ID), + zap.String("session_id", sessionID), + zap.String("task_id", turn.TaskID)) + + return turn, nil +} + +// CompleteTurn marks a turn as completed and publishes the turn.completed event. +func (s *Service) CompleteTurn(ctx context.Context, turnID string) error { + if turnID == "" { + return nil // No active turn to complete + } + + if err := s.repo.CompleteTurn(ctx, turnID); err != nil { + s.logger.Error("failed to complete turn", zap.String("turn_id", turnID), zap.Error(err)) + return err + } + + // Safety net: mark any tool calls still in "running" state as "complete" + if affected, err := s.repo.CompleteRunningToolCallsForTurn(ctx, turnID); err != nil { + s.logger.Warn("failed to complete running tool calls for turn", zap.String("turn_id", turnID), zap.Error(err)) + } else if affected > 0 { + s.logger.Info("completed stale running tool calls on turn end", + zap.String("turn_id", turnID), + zap.Int64("affected", affected)) + } + + // Fetch the completed turn to get the completed_at timestamp + turn, err := s.repo.GetTurn(ctx, turnID) + if err != nil { + s.logger.Warn("failed to refetch completed turn", zap.String("turn_id", turnID), zap.Error(err)) + // Turn was likely deleted (task deletion race), skip publishing + return nil + } + + s.publishTurnEvent(events.TurnCompleted, turn) + + s.logger.Debug("completed turn", + zap.String("turn_id", turnID), + zap.String("session_id", turn.TaskSessionID), + zap.String("task_id", turn.TaskID)) + + return nil +} + +// GetActiveTurn returns the currently active (non-completed) turn for a session. +func (s *Service) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) { + return s.repo.GetActiveTurnBySessionID(ctx, sessionID) +} + +// getOrStartTurn returns the active turn for a session, or starts a new one if none exists. +// This is used to ensure messages always have a valid turn ID. +func (s *Service) getOrStartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { + // First try to get an active turn + turn, err := s.repo.GetActiveTurnBySessionID(ctx, sessionID) + if err == nil && turn != nil { + return turn, nil + } + + // No active turn, start a new one + return s.StartTurn(ctx, sessionID) +} + +// publishTurnEvent publishes a turn event to the event bus. +func (s *Service) publishTurnEvent(eventType string, turn *models.Turn) { + if s.eventBus == nil { + return + } + if turn == nil { + s.logger.Warn("publishTurnEvent: turn is nil, skipping", zap.String("event_type", eventType)) + return + } + _ = s.eventBus.Publish(context.Background(), eventType, bus.NewEvent(eventType, "task-service", map[string]interface{}{ + "id": turn.ID, + "session_id": turn.TaskSessionID, + "task_id": turn.TaskID, + "started_at": turn.StartedAt, + "completed_at": turn.CompletedAt, + "metadata": turn.Metadata, + "created_at": turn.CreatedAt, + "updated_at": turn.UpdatedAt, + })) +} + +// GetGitSnapshots retrieves git snapshots for a session +func (s *Service) GetGitSnapshots(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { + return s.repo.GetGitSnapshotsBySession(ctx, sessionID, limit) +} + +// GetLatestGitSnapshot retrieves the latest git snapshot for a session +func (s *Service) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return s.repo.GetLatestGitSnapshot(ctx, sessionID) +} + +// GetFirstGitSnapshot retrieves the first git snapshot for a session (oldest) +func (s *Service) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return s.repo.GetFirstGitSnapshot(ctx, sessionID) +} + +// GetSessionCommits retrieves commits for a session +func (s *Service) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { + return s.repo.GetSessionCommits(ctx, sessionID) +} + +// GetLatestSessionCommit retrieves the latest commit for a session +func (s *Service) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) { + return s.repo.GetLatestSessionCommit(ctx, sessionID) +} + +// GetCumulativeDiff computes the cumulative diff from base commit to current HEAD +// by using the first snapshot's base_commit and the latest snapshot's files +func (s *Service) GetCumulativeDiff(ctx context.Context, sessionID string) (*models.CumulativeDiff, error) { + // Get the first snapshot to find the base commit + firstSnapshot, err := s.repo.GetFirstGitSnapshot(ctx, sessionID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil // No snapshots yet — valid state for fresh tasks + } + return nil, fmt.Errorf("failed to get first git snapshot: %w", err) + } + + // Get the latest snapshot for current state + latestSnapshot, err := s.repo.GetLatestGitSnapshot(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get latest git snapshot: %w", err) + } + + // Count total commits for this session + commits, err := s.repo.GetSessionCommits(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get session commits: %w", err) + } + + return &models.CumulativeDiff{ + SessionID: sessionID, + BaseCommit: firstSnapshot.BaseCommit, + HeadCommit: latestSnapshot.HeadCommit, + TotalCommits: len(commits), + Files: latestSnapshot.Files, + }, nil +} + +// GetWorkspaceInfoForSession returns workspace information for a task session. +// This implements the lifecycle.WorkspaceInfoProvider interface. +// The taskID parameter is optional - if empty, it will be looked up from the session. +func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessionID string) (*lifecycle.WorkspaceInfo, error) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get session %s: %w", sessionID, err) + } + if session == nil { + return nil, fmt.Errorf("session %s not found", sessionID) + } + + // Use session's TaskID if not provided + if taskID == "" { + taskID = session.TaskID + } + + // Get workspace path from the session's worktree + var workspacePath string + if len(session.Worktrees) > 0 { + workspacePath = session.Worktrees[0].WorktreePath + } + + // If no worktree, try to get from repository snapshot + if workspacePath == "" && session.RepositorySnapshot != nil { + if path, ok := session.RepositorySnapshot["path"].(string); ok { + workspacePath = path + } + } + + // Get agent ID from profile snapshot + var agentID string + if session.AgentProfileSnapshot != nil { + if id, ok := session.AgentProfileSnapshot["agent_id"].(string); ok { + agentID = id + } + } + + // Get ACP session ID from metadata + var acpSessionID string + if session.Metadata != nil { + if id, ok := session.Metadata["acp_session_id"].(string); ok { + acpSessionID = id + } + } + + return &lifecycle.WorkspaceInfo{ + TaskID: taskID, + SessionID: sessionID, + WorkspacePath: workspacePath, + AgentProfileID: session.AgentProfileID, + AgentID: agentID, + ACPSessionID: acpSessionID, + }, nil +} diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go new file mode 100644 index 0000000000..6f09100939 --- /dev/null +++ b/apps/backend/internal/task/service/service_workflow.go @@ -0,0 +1,391 @@ +package service + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + v1 "github.com/kandev/kandev/pkg/api/v1" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// ApproveSessionResult contains the result of approving a session +type ApproveSessionResult struct { + Session *models.TaskSession + Task *models.Task + WorkflowStep *wfmodels.WorkflowStep +} + +// ApproveSession approves a session's current step and moves it to the next step. +// It reads the step's on_turn_complete actions to determine where to transition. +// If no transition actions are configured, it falls back to the next step by position. +func (s *Service) ApproveSession(ctx context.Context, sessionID string) (*ApproveSessionResult, error) { + // Update review status to approved + if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, "approved"); err != nil { + return nil, fmt.Errorf("failed to update review status: %w", err) + } + + result := &ApproveSessionResult{} + + // Reload session to get updated review status + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to reload session: %w", err) + } + result.Session = session + + // Get the current workflow step to check for transition targets + if session.WorkflowStepID != nil && s.workflowStepGetter != nil { + step, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID) + if err != nil { + s.logger.Warn("failed to get workflow step for approval transition", + zap.String("workflow_step_id", *session.WorkflowStepID), + zap.Error(err)) + } else { + s.applyApprovalStepTransition(ctx, sessionID, step, result) + } + } + + return result, nil +} + +// applyApprovalStepTransition resolves the next workflow step and updates session/task accordingly. +func (s *Service) applyApprovalStepTransition(ctx context.Context, sessionID string, step *wfmodels.WorkflowStep, result *ApproveSessionResult) { + newStepID := s.resolveApprovalNextStep(ctx, step) + + if newStepID == "" { + s.logger.Info("session approved but no next step found (may be at final step)", + zap.String("session_id", sessionID), + zap.String("current_step", step.ID), + zap.String("current_step_name", step.Name)) + return + } + + if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, newStepID); err != nil { + s.logger.Error("failed to move session to next step after approval", + zap.String("session_id", sessionID), + zap.String("step_id", newStepID), + zap.Error(err)) + return + } + + // Also move the task to the new step + if task, err := s.repo.GetTask(ctx, result.Session.TaskID); err != nil { + s.logger.Error("failed to get task for approval transition", + zap.String("task_id", result.Session.TaskID), + zap.Error(err)) + } else { + task.WorkflowStepID = newStepID + task.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to move task to next step after approval", + zap.String("task_id", result.Session.TaskID), + zap.String("step_id", newStepID), + zap.Error(err)) + } else { + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + result.Task = task + } + } + + // Reload session with new step + result.Session, _ = s.repo.GetTaskSession(ctx, sessionID) + + // Get the new workflow step for the response + if newStep, err := s.workflowStepGetter.GetStep(ctx, newStepID); err == nil { + result.WorkflowStep = newStep + } + + s.logger.Info("session approved and moved to next step", + zap.String("session_id", sessionID), + zap.String("from_step", step.ID), + zap.String("to_step", newStepID)) +} + +// resolveApprovalNextStep determines the target step ID from a step's on_turn_complete actions, +// falling back to the next step by position when no actions are configured. +func (s *Service) resolveApprovalNextStep(ctx context.Context, step *wfmodels.WorkflowStep) string { + var newStepID string + for _, action := range step.Events.OnTurnComplete { + switch action.Type { + case "move_to_next": + nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, step.WorkflowID, step.Position) + if err != nil { + s.logger.Warn("failed to get next step by position", + zap.String("workflow_id", step.WorkflowID), + zap.Int("current_position", step.Position), + zap.Error(err)) + } else if nextStep != nil { + newStepID = nextStep.ID + } + case "move_to_step": + if stepID, ok := action.Config["step_id"].(string); ok && stepID != "" { + newStepID = stepID + } + } + if newStepID != "" { + return newStepID + } + } + + // Fall back to next step by position if no transition actions found + if len(step.Events.OnTurnComplete) == 0 { + nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, step.WorkflowID, step.Position) + if err != nil { + s.logger.Warn("failed to get next step by position for fallback", + zap.String("workflow_id", step.WorkflowID), + zap.Int("current_position", step.Position), + zap.Error(err)) + } else if nextStep != nil { + s.logger.Info("using next step by position for approval transition (fallback)", + zap.String("current_step", step.Name), + zap.String("next_step", nextStep.Name)) + newStepID = nextStep.ID + } + } + + return newStepID +} + +// UpdateTaskState updates the state of a task, moves it to the matching column, +// and publishes a task.state_changed event +func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + oldState := task.State + + if err := s.repo.UpdateTaskState(ctx, id, state); err != nil { + s.logger.Error("failed to update task state", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + // Reload task to get updated state + task, err = s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + s.logger.Info("task state updated", + zap.String("task_id", id), + zap.String("workflow_step_id", task.WorkflowStepID), + zap.String("state", string(task.State))) + + s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState) + s.logger.Info("task state changed", + zap.String("task_id", id), + zap.String("old_state", string(oldState)), + zap.String("new_state", string(state))) + + return task, nil +} + +// UpdateTaskMetadata updates only the metadata of a task (merges with existing) +func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata map[string]interface{}) (*models.Task, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + // Merge metadata (existing keys are preserved, new keys are added/updated) + if task.Metadata == nil { + task.Metadata = make(map[string]interface{}) + } + for k, v := range metadata { + task.Metadata[k] = v + } + task.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to update task metadata", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + s.logger.Debug("task metadata updated", zap.String("task_id", id), zap.Any("metadata", metadata)) + return task, nil +} + +// MoveTaskResult contains the result of a MoveTask operation. +type MoveTaskResult struct { + Task *models.Task + WorkflowStep *wfmodels.WorkflowStep +} + +// MoveTask moves a task to a different workflow step and position +func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, workflowStepID string, position int) (*MoveTaskResult, error) { + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return nil, err + } + + if err := s.checkMoveTaskApproval(ctx, id, task.WorkflowStepID, workflowStepID); err != nil { + return nil, err + } + + oldState := task.State + task.WorkflowID = workflowID + task.WorkflowStepID = workflowStepID + task.Position = position + task.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to move task", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + s.syncActiveSessionWorkflowStep(ctx, id, task.WorkflowStepID) + + // Publish state_changed event if state changed, otherwise just updated + if oldState != task.State { + s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState) + } else { + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + } + + s.logger.Info("task moved", + zap.String("task_id", id), + zap.String("workflow_id", workflowID), + zap.String("workflow_step_id", workflowStepID), + zap.Int("position", position)) + + result := &MoveTaskResult{Task: task} + + // Fetch the workflow step info if getter is available + if s.workflowStepGetter != nil { + step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) + if err != nil { + s.logger.Warn("failed to get workflow step for MoveTask response", + zap.String("workflow_step_id", workflowStepID), + zap.Error(err)) + // Don't fail the operation, just log and continue + } else { + result.WorkflowStep = step + } + } + + return result, nil +} + +// checkMoveTaskApproval returns an error when the task's primary session has a pending +// review and the caller is trying to move it to a different step. +func (s *Service) checkMoveTaskApproval(ctx context.Context, taskID, currentStepID, targetStepID string) error { + if currentStepID == targetStepID { + return nil + } + primarySession, err := s.repo.GetPrimarySessionByTaskID(ctx, taskID) + if err != nil || primarySession == nil { + return nil + } + if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" { + return fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes") + } + return nil +} + +// syncActiveSessionWorkflowStep updates the active session's workflow_step_id to match the +// task's new step when they differ. Failures are logged but do not abort the move. +func (s *Service) syncActiveSessionWorkflowStep(ctx context.Context, taskID, workflowStepID string) { + if workflowStepID == "" { + return + } + activeSession, err := s.repo.GetActiveTaskSessionByTaskID(ctx, taskID) + if err != nil || activeSession == nil { + return + } + if activeSession.WorkflowStepID != nil && *activeSession.WorkflowStepID == workflowStepID { + return + } + if err := s.repo.UpdateSessionWorkflowStep(ctx, activeSession.ID, workflowStepID); err != nil { + s.logger.Warn("failed to update session workflow step after task move", + zap.String("task_id", taskID), + zap.String("session_id", activeSession.ID), + zap.String("workflow_step_id", workflowStepID), + zap.Error(err)) + return + } + s.logger.Info("updated session workflow step to match moved task", + zap.String("task_id", taskID), + zap.String("session_id", activeSession.ID), + zap.String("workflow_step_id", workflowStepID)) +} + +// CountTasksByWorkflow returns the number of tasks in a workflow +func (s *Service) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { + return s.repo.CountTasksByWorkflow(ctx, workflowID) +} + +// CountTasksByWorkflowStep returns the number of tasks in a workflow step +func (s *Service) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { + return s.repo.CountTasksByWorkflowStep(ctx, stepID) +} + +// BulkMoveTasksResult contains the result of a BulkMoveTasks operation. +type BulkMoveTasksResult struct { + MovedCount int +} + +// BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step. +// If sourceStepID is empty, all tasks in the source workflow are moved. +func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { + // Get the tasks to move + var tasks []*models.Task + var err error + if sourceStepID != "" { + tasks, err = s.repo.ListTasksByWorkflowStep(ctx, sourceStepID) + } else { + tasks, err = s.repo.ListTasks(ctx, sourceWorkflowID) + } + if err != nil { + return nil, fmt.Errorf("failed to list tasks for bulk move: %w", err) + } + + if len(tasks) == 0 { + return &BulkMoveTasksResult{MovedCount: 0}, nil + } + + now := time.Now().UTC() + for i, task := range tasks { + task.WorkflowID = targetWorkflowID + task.WorkflowStepID = targetStepID + task.Position = i + task.UpdatedAt = now + + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to move task in bulk move", + zap.String("task_id", task.ID), + zap.Error(err)) + return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) + } + + // Update active session's workflow_step_id + activeSession, err := s.repo.GetActiveTaskSessionByTaskID(ctx, task.ID) + if err == nil && activeSession != nil { + if activeSession.WorkflowStepID == nil || *activeSession.WorkflowStepID != targetStepID { + if err := s.repo.UpdateSessionWorkflowStep(ctx, activeSession.ID, targetStepID); err != nil { + s.logger.Warn("failed to update session workflow step during bulk move", + zap.String("task_id", task.ID), + zap.String("session_id", activeSession.ID), + zap.Error(err)) + } + } + } + + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + } + + s.logger.Info("bulk moved tasks", + zap.String("source_workflow_id", sourceWorkflowID), + zap.String("source_step_id", sourceStepID), + zap.String("target_workflow_id", targetWorkflowID), + zap.String("target_step_id", targetStepID), + zap.Int("moved_count", len(tasks))) + + return &BulkMoveTasksResult{MovedCount: len(tasks)}, nil +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 0b936a6372..bc9169f116 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -79,6 +79,25 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if err != nil { return nil, err } + if err := applyBasicSettings(settings, req); err != nil { + return nil, err + } + if err := s.applyChatSubmitKey(settings, req); err != nil { + return nil, err + } + if err := applyLSPSettings(settings, req); err != nil { + return nil, err + } + settings.UpdatedAt = time.Now().UTC() + if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { + return nil, err + } + s.publishUserSettingsEvent(ctx, settings) + return settings, nil +} + +// applyBasicSettings copies simple (non-validated) fields from req to settings. +func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { if req.WorkspaceID != nil { settings.WorkspaceID = *req.WorkspaceID } @@ -103,38 +122,44 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if req.EnablePreviewOnClick != nil { settings.EnablePreviewOnClick = *req.EnablePreviewOnClick } - if req.ChatSubmitKey != nil { - key := strings.TrimSpace(*req.ChatSubmitKey) - if key != "enter" && key != "cmd_enter" { - return nil, errors.New("chat_submit_key must be 'enter' or 'cmd_enter'") - } - s.logger.Info("[Settings] Setting ChatSubmitKey", zap.String("value", key)) - settings.ChatSubmitKey = key - } if req.ReviewAutoMarkOnScroll != nil { settings.ReviewAutoMarkOnScroll = *req.ReviewAutoMarkOnScroll } + return nil +} + +// applyChatSubmitKey validates and applies the chat_submit_key setting. +func (s *Service) applyChatSubmitKey(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + if req.ChatSubmitKey == nil { + return nil + } + key := strings.TrimSpace(*req.ChatSubmitKey) + if key != "enter" && key != "cmd_enter" { + return errors.New("chat_submit_key must be 'enter' or 'cmd_enter'") + } + s.logger.Info("[Settings] Setting ChatSubmitKey", zap.String("value", key)) + settings.ChatSubmitKey = key + return nil +} + +// applyLSPSettings validates and applies LSP-related settings. +func applyLSPSettings(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { if req.LspAutoStartLanguages != nil { if err := validateLSPLanguages(*req.LspAutoStartLanguages); err != nil { - return nil, fmt.Errorf("lsp_auto_start_languages: %w", err) + return fmt.Errorf("lsp_auto_start_languages: %w", err) } settings.LspAutoStartLanguages = *req.LspAutoStartLanguages } if req.LspAutoInstallLanguages != nil { if err := validateLSPLanguages(*req.LspAutoInstallLanguages); err != nil { - return nil, fmt.Errorf("lsp_auto_install_languages: %w", err) + return fmt.Errorf("lsp_auto_install_languages: %w", err) } settings.LspAutoInstallLanguages = *req.LspAutoInstallLanguages } if req.LspServerConfigs != nil { settings.LspServerConfigs = *req.LspServerConfigs } - settings.UpdatedAt = time.Now().UTC() - if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { - return nil, err - } - s.publishUserSettingsEvent(ctx, settings) - return settings, nil + return nil } func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models.UserSettings) { diff --git a/apps/backend/internal/workflow/handlers/handlers.go b/apps/backend/internal/workflow/handlers/handlers.go index e0a11151cc..3a5d1e48b4 100644 --- a/apps/backend/internal/workflow/handlers/handlers.go +++ b/apps/backend/internal/workflow/handlers/handlers.go @@ -262,24 +262,11 @@ func (h *Handlers) wsListTemplates(ctx context.Context, msg *ws.Message) (*ws.Me return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsGetTemplateRequest struct { - ID string `json:"id"` -} - func (h *Handlers) wsGetTemplate(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetTemplateRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - resp, err := h.controller.GetTemplate(ctx, req.ID) - if err != nil { - h.logger.Error("failed to get template", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Template not found", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) + return h.wsGetByID(ctx, msg, "failed to get template", "Template not found", + func(ctx context.Context, id string) (any, error) { + return h.controller.GetTemplate(ctx, id) + }) } // WS handlers - Steps @@ -293,35 +280,18 @@ func (h *Handlers) wsListSteps(ctx context.Context, msg *ws.Message) (*ws.Messag if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.WorkflowID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) - } - resp, err := h.controller.ListStepsByWorkflow(ctx, controller.ListStepsRequest{WorkflowID: req.WorkflowID}) - if err != nil { - h.logger.Error("failed to list steps", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list steps", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsGetStepRequest struct { - ID string `json:"id"` + return h.wsHandleStringField(ctx, msg, req.WorkflowID, "workflow_id", + "failed to list steps", "Failed to list steps", + func(ctx context.Context, workflowID string) (any, error) { + return h.controller.ListStepsByWorkflow(ctx, controller.ListStepsRequest{WorkflowID: workflowID}) + }) } func (h *Handlers) wsGetStep(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetStepRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.ID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) - } - resp, err := h.controller.GetStep(ctx, req.ID) - if err != nil { - h.logger.Error("failed to get step", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Step not found", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) + return h.wsGetByID(ctx, msg, "failed to get step", "Step not found", + func(ctx context.Context, id string) (any, error) { + return h.controller.GetStep(ctx, id) + }) } type wsCreateStepsRequest struct { @@ -358,14 +328,10 @@ func (h *Handlers) wsListHistory(ctx context.Context, msg *ws.Message) (*ws.Mess if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.SessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - resp, err := h.controller.ListHistoryBySession(ctx, controller.ListHistoryRequest{SessionID: req.SessionID}) - if err != nil { - h.logger.Error("failed to list history", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list history", nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) + return h.wsHandleStringField(ctx, msg, req.SessionID, "session_id", + "failed to list history", "Failed to list history", + func(ctx context.Context, sessionID string) (any, error) { + return h.controller.ListHistoryBySession(ctx, controller.ListHistoryRequest{SessionID: sessionID}) + }) } diff --git a/apps/backend/internal/workflow/handlers/ws_helpers.go b/apps/backend/internal/workflow/handlers/ws_helpers.go new file mode 100644 index 0000000000..c0e832eaf6 --- /dev/null +++ b/apps/backend/internal/workflow/handlers/ws_helpers.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "context" + + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +func (h *Handlers) wsGetByID( + ctx context.Context, msg *ws.Message, + logErrMsg, clientErrMsg string, + fn func(context.Context, string) (any, error), +) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := fn(ctx, req.ID) + if err != nil { + h.logger.Error(logErrMsg, zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, clientErrMsg, nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +func (h *Handlers) wsHandleStringField( + ctx context.Context, msg *ws.Message, + fieldValue, fieldName, logErrMsg, clientErrMsg string, + fn func(context.Context, string) (any, error), +) (*ws.Message, error) { + if fieldValue == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, fieldName+" is required", nil) + } + resp, err := fn(ctx, fieldValue) + if err != nil { + h.logger.Error(logErrMsg, zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, clientErrMsg, nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index 522ad27806..47fc3fea08 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -551,25 +551,7 @@ func (r *Repository) DeleteTemplate(ctx context.Context, id string) error { return nil } -// ListTemplates returns all workflow templates. -func (r *Repository) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, description, is_system, steps, created_at, updated_at - FROM workflow_templates - ORDER BY is_system DESC, - CASE - WHEN id = 'simple' THEN 1 - WHEN id = 'standard' THEN 2 - WHEN id = 'architecture' THEN 3 - ELSE 999 - END, - name ASC - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - +func scanTemplateRows(rows *sql.Rows) ([]*models.WorkflowTemplate, error) { var result []*models.WorkflowTemplate for rows.Next() { template := &models.WorkflowTemplate{} @@ -591,6 +573,27 @@ func (r *Repository) ListTemplates(ctx context.Context) ([]*models.WorkflowTempl return result, rows.Err() } +// ListTemplates returns all workflow templates. +func (r *Repository) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, description, is_system, steps, created_at, updated_at + FROM workflow_templates + ORDER BY is_system DESC, + CASE + WHEN id = 'simple' THEN 1 + WHEN id = 'standard' THEN 2 + WHEN id = 'architecture' THEN 3 + ELSE 999 + END, + name ASC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanTemplateRows(rows) +} + // GetSystemTemplates returns only system workflow templates. func (r *Repository) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) { rows, err := r.db.QueryContext(ctx, ` @@ -601,26 +604,7 @@ func (r *Repository) GetSystemTemplates(ctx context.Context) ([]*models.Workflow return nil, err } defer func() { _ = rows.Close() }() - - var result []*models.WorkflowTemplate - for rows.Next() { - template := &models.WorkflowTemplate{} - var stepsJSON string - var isSystem int - - err := rows.Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt) - if err != nil { - return nil, err - } - - template.IsSystem = isSystem == 1 - if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil { - return nil, fmt.Errorf("failed to unmarshal steps for template %s: %w", template.ID, err) - } - - result = append(result, template) - } - return result, rows.Err() + return scanTemplateRows(rows) } // ============================================================================ diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go index 6f209b58ae..ffa952c21e 100644 --- a/apps/backend/internal/worktree/manager.go +++ b/apps/backend/internal/worktree/manager.go @@ -227,19 +227,47 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err // createWorktree performs the actual git worktree creation. func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef string) (*Worktree, error) { - // Generate a unique worktree ID and random suffix + worktreeDirName, branchName := m.buildWorktreeNames(req) + + worktreePath, err := m.config.WorktreePath(worktreeDirName) + if err != nil { + return nil, fmt.Errorf("failed to get worktree path: %w", err) + } + + worktreeID, err := m.gitAddWorktree(ctx, req.RepositoryPath, branchName, worktreePath, baseRef) + if err != nil { + return nil, err + } + + wt := m.buildWorktreeRecord(worktreeID, req, worktreePath, branchName) + + if err := m.persistAndCacheWorktree(ctx, wt, req, worktreePath); err != nil { + return nil, err + } + + if err := m.runWorktreeSetupScript(ctx, wt, req.RepositoryPath); err != nil { + return nil, err + } + + m.logger.Info("created worktree", + zap.String("session_id", req.SessionID), + zap.String("task_id", req.TaskID), + zap.String("path", worktreePath), + zap.String("branch", branchName)) + + return wt, nil +} + +// buildWorktreeNames derives the filesystem directory name and git branch name for a new worktree. +func (m *Manager) buildWorktreeNames(req CreateRequest) (dirName, branchName string) { worktreeID := uuid.New().String() dirSuffix := worktreeID[:8] // Use first 8 chars of UUID for worktree dir uniqueness branchSuffix := SmallSuffix(3) prefix := NormalizeBranchPrefix(req.WorktreeBranchPrefix) - // Generate semantic name from task title if available - var worktreeDirName, branchName string if req.TaskTitle != "" { // Use semantic naming: {sanitized-title}_{suffix} - semanticName := SemanticWorktreeName(req.TaskTitle, dirSuffix) - worktreeDirName = semanticName - // For branch, use sanitized title (20 chars max) + suffix + dirName = SemanticWorktreeName(req.TaskTitle, dirSuffix) sanitizedTitle := SanitizeForBranch(req.TaskTitle, 20) if sanitizedTitle == "" { sanitizedTitle = SanitizeForBranch(req.TaskID, 20) @@ -247,33 +275,35 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef branchName = prefix + sanitizedTitle + "-" + branchSuffix } else { // Fallback to task ID based naming - worktreeDirName = req.TaskID + "_" + dirSuffix + dirName = req.TaskID + "_" + dirSuffix branchName = prefix + SanitizeForBranch(req.TaskID, 20) + "-" + branchSuffix } + return dirName, branchName +} - worktreePath, err := m.config.WorktreePath(worktreeDirName) - if err != nil { - return nil, fmt.Errorf("failed to get worktree path: %w", err) - } - - // Create worktree with new branch +// gitAddWorktree runs "git worktree add" and returns the new worktree UUID. +func (m *Manager) gitAddWorktree(ctx context.Context, repoPath, branchName, worktreePath, baseRef string) (string, error) { + worktreeID := uuid.New().String() // git worktree add -b cmd := exec.CommandContext(ctx, "git", "worktree", "add", "-b", branchName, worktreePath, baseRef) - cmd.Dir = req.RepositoryPath - + cmd.Dir = repoPath output, err := cmd.CombinedOutput() if err != nil { m.logger.Error("git worktree add failed", zap.String("output", string(output)), zap.Error(err)) - return nil, fmt.Errorf("%w: %s", ErrGitCommandFailed, string(output)) + return "", fmt.Errorf("%w: %s", ErrGitCommandFailed, string(output)) } + return worktreeID, nil +} +// buildWorktreeRecord constructs an in-memory Worktree value from a completed git worktree add. +func (m *Manager) buildWorktreeRecord(worktreeID string, req CreateRequest, worktreePath, branchName string) *Worktree { now := time.Now() - wt := &Worktree{ + return &Worktree{ ID: worktreeID, SessionID: req.SessionID, TaskID: req.TaskID, @@ -286,21 +316,13 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef CreatedAt: now, UpdatedAt: now, } +} - // Persist to store +// persistAndCacheWorktree saves the worktree to the store and updates the in-memory cache. +func (m *Manager) persistAndCacheWorktree(ctx context.Context, wt *Worktree, req CreateRequest, worktreePath string) error { if m.store != nil { - if req.SessionID == "" { - m.logger.Warn("skipping worktree persistence: missing session_id", - zap.String("task_id", req.TaskID), - zap.String("worktree_id", wt.ID)) - } else { - if err := m.store.CreateWorktree(ctx, wt); err != nil { - // Cleanup on failure - if cleanupErr := m.removeWorktreeDir(ctx, worktreePath, req.RepositoryPath); cleanupErr != nil { - m.logger.Warn("failed to cleanup worktree after persist failure", zap.Error(cleanupErr)) - } - return nil, fmt.Errorf("failed to persist worktree: %w", err) - } + if err := m.persistWorktree(ctx, wt, req, worktreePath); err != nil { + return err } } @@ -310,74 +332,26 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef m.worktrees[req.SessionID] = wt m.mu.Unlock() } + return nil +} - // Execute setup script if configured - if m.scriptMsgHandler != nil && m.repoProvider != nil { - repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID) - if err != nil { - m.logger.Warn("failed to fetch repository for setup script", - zap.String("repository_id", wt.RepositoryID), - zap.Error(err)) - } else if strings.TrimSpace(repo.SetupScript) != "" { - m.logger.Info("executing setup script for worktree", - zap.String("worktree_id", wt.ID), - zap.String("repository_id", wt.RepositoryID)) - - scriptReq := ScriptExecutionRequest{ - SessionID: wt.SessionID, - TaskID: wt.TaskID, - RepositoryID: wt.RepositoryID, - Script: repo.SetupScript, - WorkingDir: wt.Path, - ScriptType: "setup", - } - - if err := m.scriptMsgHandler.ExecuteSetupScript(ctx, scriptReq); err != nil { - // Setup failed - cleanup worktree - m.logger.Error("setup script failed, cleaning up worktree", - zap.String("worktree_id", wt.ID), - zap.Error(err)) - - // Remove from cache - if wt.SessionID != "" { - m.mu.Lock() - delete(m.worktrees, wt.SessionID) - m.mu.Unlock() - } - - // Remove directory - if cleanupErr := m.removeWorktreeDir(ctx, wt.Path, req.RepositoryPath); cleanupErr != nil { - m.logger.Warn("failed to cleanup worktree after setup failure", - zap.Error(cleanupErr)) - } - - // Mark as deleted in database - if m.store != nil { - now := time.Now() - wt.Status = StatusDeleted - wt.DeletedAt = &now - wt.UpdatedAt = now - if updateErr := m.store.UpdateWorktree(ctx, wt); updateErr != nil { - m.logger.Warn("failed to update worktree status", - zap.Error(updateErr)) - } - } - - return nil, fmt.Errorf("setup script failed: %w", err) - } - - m.logger.Info("setup script completed successfully", - zap.String("worktree_id", wt.ID)) - } +// persistWorktree writes the worktree to persistent storage, logging a warning when +// session_id is missing and cleaning up the git worktree directory on failure. +func (m *Manager) persistWorktree(ctx context.Context, wt *Worktree, req CreateRequest, worktreePath string) error { + if req.SessionID == "" { + m.logger.Warn("skipping worktree persistence: missing session_id", + zap.String("task_id", req.TaskID), + zap.String("worktree_id", wt.ID)) + return nil } - - m.logger.Info("created worktree", - zap.String("session_id", req.SessionID), - zap.String("task_id", req.TaskID), - zap.String("path", worktreePath), - zap.String("branch", branchName)) - - return wt, nil + if err := m.store.CreateWorktree(ctx, wt); err != nil { + // Cleanup git worktree on store failure + if cleanupErr := m.removeWorktreeDir(ctx, worktreePath, req.RepositoryPath); cleanupErr != nil { + m.logger.Warn("failed to cleanup worktree after persist failure", zap.Error(cleanupErr)) + } + return fmt.Errorf("failed to persist worktree: %w", err) + } + return nil } // GetBySessionID returns the worktree for a session, if it exists. @@ -475,37 +449,7 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch }() // Execute cleanup script BEFORE removing directory - if m.scriptMsgHandler != nil && m.repoProvider != nil { - repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID) - if err != nil { - m.logger.Warn("failed to fetch repository for cleanup script", - zap.String("repository_id", wt.RepositoryID), - zap.Error(err)) - } else if strings.TrimSpace(repo.CleanupScript) != "" { - m.logger.Info("executing cleanup script for worktree", - zap.String("worktree_id", wt.ID), - zap.String("repository_id", wt.RepositoryID)) - - scriptReq := ScriptExecutionRequest{ - SessionID: wt.SessionID, - TaskID: wt.TaskID, - RepositoryID: wt.RepositoryID, - Script: repo.CleanupScript, - WorkingDir: wt.Path, - ScriptType: "cleanup", - } - - if err := m.scriptMsgHandler.ExecuteCleanupScript(ctx, scriptReq); err != nil { - // Log error but continue with deletion - m.logger.Warn("cleanup script failed, proceeding with deletion", - zap.String("worktree_id", wt.ID), - zap.Error(err)) - } else { - m.logger.Info("cleanup script completed successfully", - zap.String("worktree_id", wt.ID)) - } - } - } + m.runWorktreeCleanupScript(ctx, wt) // Remove worktree directory if err := m.removeWorktreeDir(ctx, wt.Path, wt.RepositoryPath); err != nil { @@ -563,6 +507,101 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch return nil } +func (m *Manager) runWorktreeSetupScript(ctx context.Context, wt *Worktree, repositoryPath string) error { + if m.scriptMsgHandler == nil || m.repoProvider == nil { + return nil + } + repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID) + if err != nil { + m.logger.Warn("failed to fetch repository for setup script", + zap.String("repository_id", wt.RepositoryID), + zap.Error(err)) + return nil + } + if strings.TrimSpace(repo.SetupScript) == "" { + return nil + } + m.logger.Info("executing setup script for worktree", + zap.String("worktree_id", wt.ID), + zap.String("repository_id", wt.RepositoryID)) + scriptReq := ScriptExecutionRequest{ + SessionID: wt.SessionID, + TaskID: wt.TaskID, + RepositoryID: wt.RepositoryID, + Script: repo.SetupScript, + WorkingDir: wt.Path, + ScriptType: "setup", + } + if err := m.scriptMsgHandler.ExecuteSetupScript(ctx, scriptReq); err != nil { + m.logger.Error("setup script failed, cleaning up worktree", + zap.String("worktree_id", wt.ID), + zap.Error(err)) + m.cleanupWorktreeOnSetupFailure(ctx, wt, repositoryPath) + return fmt.Errorf("setup script failed: %w", err) + } + m.logger.Info("setup script completed successfully", zap.String("worktree_id", wt.ID)) + return nil +} + +// cleanupWorktreeOnSetupFailure removes the in-memory cache entry, deletes the worktree +// directory, and marks the worktree as deleted in the store after a setup script failure. +func (m *Manager) cleanupWorktreeOnSetupFailure(ctx context.Context, wt *Worktree, repositoryPath string) { + if wt.SessionID != "" { + m.mu.Lock() + delete(m.worktrees, wt.SessionID) + m.mu.Unlock() + } + if cleanupErr := m.removeWorktreeDir(ctx, wt.Path, repositoryPath); cleanupErr != nil { + m.logger.Warn("failed to cleanup worktree after setup failure", zap.Error(cleanupErr)) + } + if m.store == nil { + return + } + now := time.Now() + wt.Status = StatusDeleted + wt.DeletedAt = &now + wt.UpdatedAt = now + if updateErr := m.store.UpdateWorktree(ctx, wt); updateErr != nil { + m.logger.Warn("failed to update worktree status", zap.Error(updateErr)) + } +} + +// runWorktreeCleanupScript executes the repository cleanup script for a worktree before removal. +func (m *Manager) runWorktreeCleanupScript(ctx context.Context, wt *Worktree) { + if m.scriptMsgHandler == nil || m.repoProvider == nil { + return + } + repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID) + if err != nil { + m.logger.Warn("failed to fetch repository for cleanup script", + zap.String("repository_id", wt.RepositoryID), + zap.Error(err)) + return + } + if strings.TrimSpace(repo.CleanupScript) == "" { + return + } + m.logger.Info("executing cleanup script for worktree", + zap.String("worktree_id", wt.ID), + zap.String("repository_id", wt.RepositoryID)) + scriptReq := ScriptExecutionRequest{ + SessionID: wt.SessionID, + TaskID: wt.TaskID, + RepositoryID: wt.RepositoryID, + Script: repo.CleanupScript, + WorkingDir: wt.Path, + ScriptType: "cleanup", + } + if err := m.scriptMsgHandler.ExecuteCleanupScript(ctx, scriptReq); err != nil { + m.logger.Warn("cleanup script failed, proceeding with deletion", + zap.String("worktree_id", wt.ID), + zap.Error(err)) + } else { + m.logger.Info("cleanup script completed successfully", + zap.String("worktree_id", wt.ID)) + } +} + // CleanupWorktrees removes provided worktrees without re-fetching from the store. func (m *Manager) CleanupWorktrees(ctx context.Context, worktrees []*Worktree) error { if len(worktrees) == 0 { diff --git a/apps/backend/internal/worktree/script_message_handler.go b/apps/backend/internal/worktree/script_message_handler.go index 263c6f03d1..d2869a29ee 100644 --- a/apps/backend/internal/worktree/script_message_handler.go +++ b/apps/backend/internal/worktree/script_message_handler.go @@ -130,29 +130,7 @@ func (h *DefaultScriptMessageHandler) executeScript(_ context.Context, req Scrip exitCode, scriptErr := h.runScriptWithOutput(scriptCtx, req, msg) // Update final status - if scriptErr != nil { - msg.Metadata["status"] = "failed" - msg.Metadata["error"] = scriptErr.Error() - if msg.Content == "" { - msg.Content = fmt.Sprintf("Script execution failed: %s", scriptErr.Error()) - } else { - msg.Content += fmt.Sprintf("\n\nScript execution failed: %s", scriptErr.Error()) - } - } else if exitCode == 0 { - msg.Metadata["status"] = "exited" - msg.Metadata["exit_code"] = exitCode - if msg.Content == "" { - msg.Content = "Script completed successfully" - } - } else { - msg.Metadata["status"] = "failed" - msg.Metadata["exit_code"] = exitCode - if msg.Content == "" { - msg.Content = fmt.Sprintf("Script failed with exit code: %d", exitCode) - } else { - msg.Content += fmt.Sprintf("\n\nScript failed with exit code: %d", exitCode) - } - } + applyScriptFinalStatus(msg, exitCode, scriptErr) msg.Metadata["completed_at"] = time.Now().UTC().Format(time.RFC3339Nano) if updateErr := h.taskService.UpdateMessage(scriptCtx, msg); updateErr != nil { @@ -166,14 +144,48 @@ func (h *DefaultScriptMessageHandler) executeScript(_ context.Context, req Scrip zap.Int("exit_code", exitCode), zap.Bool("success", exitCode == 0)) - // Return error if setup script failed - if failOnError && (scriptErr != nil || exitCode != 0) { - if scriptErr != nil { - return scriptErr + return scriptExecutionError(failOnError, exitCode, scriptErr) +} + +// applyScriptFinalStatus updates the message metadata and content based on script outcome. +func applyScriptFinalStatus(msg *models.Message, exitCode int, scriptErr error) { + switch { + case scriptErr != nil: + msg.Metadata["status"] = "failed" + msg.Metadata["error"] = scriptErr.Error() + if msg.Content == "" { + msg.Content = fmt.Sprintf("Script execution failed: %s", scriptErr.Error()) + } else { + msg.Content += fmt.Sprintf("\n\nScript execution failed: %s", scriptErr.Error()) } + case exitCode == 0: + msg.Metadata["status"] = "exited" + msg.Metadata["exit_code"] = exitCode + if msg.Content == "" { + msg.Content = "Script completed successfully" + } + default: + msg.Metadata["status"] = "failed" + msg.Metadata["exit_code"] = exitCode + if msg.Content == "" { + msg.Content = fmt.Sprintf("Script failed with exit code: %d", exitCode) + } else { + msg.Content += fmt.Sprintf("\n\nScript failed with exit code: %d", exitCode) + } + } +} + +// scriptExecutionError returns an error if failOnError is set and the script did not succeed. +func scriptExecutionError(failOnError bool, exitCode int, scriptErr error) error { + if !failOnError { + return nil + } + if scriptErr != nil { + return scriptErr + } + if exitCode != 0 { return fmt.Errorf("script exited with code %d", exitCode) } - return nil } @@ -201,11 +213,9 @@ func (h *DefaultScriptMessageHandler) createScriptMessage(ctx context.Context, r // runScriptWithOutput runs the script and captures output, streaming it to the message. // The passed context should already have an appropriate timeout set. func (h *DefaultScriptMessageHandler) runScriptWithOutput(ctx context.Context, req ScriptExecutionRequest, msg *models.Message) (int, error) { - // Run script with sh -c to support complex commands cmd := exec.CommandContext(ctx, "sh", "-c", req.Script) cmd.Dir = req.WorkingDir - // Create pipes for stdout and stderr stdoutPipe, err := cmd.StdoutPipe() if err != nil { return -1, fmt.Errorf("failed to create stdout pipe: %w", err) @@ -214,8 +224,6 @@ func (h *DefaultScriptMessageHandler) runScriptWithOutput(ctx context.Context, r if err != nil { return -1, fmt.Errorf("failed to create stderr pipe: %w", err) } - - // Start the command if err := cmd.Start(); err != nil { return -1, fmt.Errorf("failed to start script: %w", err) } @@ -224,91 +232,53 @@ func (h *DefaultScriptMessageHandler) runScriptWithOutput(ctx context.Context, r zap.String("message_id", msg.ID), zap.String("command", req.Script)) - // Stream output var outputBuf bytes.Buffer var wg sync.WaitGroup var mu sync.Mutex - // Stream stdout - wg.Add(1) - go func() { - defer wg.Done() - buf := make([]byte, 1024) - for { - n, err := stdoutPipe.Read(buf) - if n > 0 { - mu.Lock() - outputBuf.Write(buf[:n]) - msg.Content = outputBuf.String() - mu.Unlock() - - // Update message with incremental output (best-effort) - if updateErr := h.taskService.UpdateMessage(context.Background(), msg); updateErr != nil { - h.logger.Debug("failed to update message with output", - zap.String("message_id", msg.ID), - zap.Error(updateErr)) - } - } - if err != nil { - if err != io.EOF { - h.logger.Debug("error reading stdout", - zap.Error(err)) - } - break - } - } - }() - - // Stream stderr - wg.Add(1) - go func() { - defer wg.Done() - buf := make([]byte, 1024) - for { - n, err := stderrPipe.Read(buf) - if n > 0 { - mu.Lock() - outputBuf.Write(buf[:n]) - msg.Content = outputBuf.String() - mu.Unlock() - - // Update message with incremental output (best-effort) - if updateErr := h.taskService.UpdateMessage(context.Background(), msg); updateErr != nil { - h.logger.Debug("failed to update message with output", - zap.String("message_id", msg.ID), - zap.Error(updateErr)) - } - } - if err != nil { - if err != io.EOF { - h.logger.Debug("error reading stderr", - zap.Error(err)) - } - break - } - } - }() - - // Wait for output streaming to finish + wg.Add(2) + go h.streamPipeOutput(stdoutPipe, &outputBuf, &mu, msg, &wg) + go h.streamPipeOutput(stderrPipe, &outputBuf, &mu, msg, &wg) wg.Wait() - // Wait for command to complete err = cmd.Wait() - - // Get exit code exitCode := 0 if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } else { - // Other error (e.g., timeout, command not found) return -1, err } } - return exitCode, nil } +// streamPipeOutput reads from pipe and streams output into msg.Content incrementally. +func (h *DefaultScriptMessageHandler) streamPipeOutput(pipe io.Reader, outputBuf *bytes.Buffer, mu *sync.Mutex, msg *models.Message, wg *sync.WaitGroup) { + defer wg.Done() + buf := make([]byte, 1024) + for { + n, err := pipe.Read(buf) + if n > 0 { + mu.Lock() + outputBuf.Write(buf[:n]) + msg.Content = outputBuf.String() + mu.Unlock() + if updateErr := h.taskService.UpdateMessage(context.Background(), msg); updateErr != nil { + h.logger.Debug("failed to update message with output", + zap.String("message_id", msg.ID), + zap.Error(updateErr)) + } + } + if err != nil { + if err != io.EOF { + h.logger.Debug("error reading pipe output", zap.Error(err)) + } + break + } + } +} + // runScriptWithoutMessage runs a script without message tracking (used when session is deleted). // The passed context should already have an appropriate timeout set. func (h *DefaultScriptMessageHandler) runScriptWithoutMessage(ctx context.Context, req ScriptExecutionRequest, failOnError bool) error { diff --git a/apps/backend/internal/worktree/store.go b/apps/backend/internal/worktree/store.go index 6ae6d1381b..94805ff21c 100644 --- a/apps/backend/internal/worktree/store.go +++ b/apps/backend/internal/worktree/store.go @@ -159,32 +159,12 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree return wt, nil } -// GetWorktreeBySessionID retrieves the worktree by session ID. -func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID string) (*Worktree, error) { +func scanWorktreeRow(row *sql.Row) (*Worktree, error) { wt := &Worktree{} var mergedAt, deletedAt sql.NullTime var repositoryPath, baseBranch sql.NullString - err := s.db.QueryRowContext(ctx, s.db.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - INNER JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE tsw.session_id = ? AND tsw.status = ? - `), sessionID, StatusActive).Scan( + err := row.Scan( &wt.ID, &wt.SessionID, &wt.TaskID, @@ -199,9 +179,8 @@ func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID stri &mergedAt, &deletedAt, ) - if err == sql.ErrNoRows { - return nil, nil // Not found, return nil without error + return nil, nil } if err != nil { return nil, err @@ -223,14 +202,35 @@ func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID stri return wt, nil } +// GetWorktreeBySessionID retrieves the worktree by session ID. +func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID string) (*Worktree, error) { + row := s.db.QueryRowContext(ctx, s.db.Rebind(` + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + INNER JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE tsw.session_id = ? AND tsw.status = ? + `), sessionID, StatusActive) + return scanWorktreeRow(row) +} + // GetWorktreeByTaskID retrieves the most recent active worktree by task ID. // Since multiple worktrees can exist per task, this returns the most recently created active one. func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (*Worktree, error) { - wt := &Worktree{} - var mergedAt, deletedAt sql.NullTime - var repositoryPath, baseBranch sql.NullString - - err := s.db.QueryRowContext(ctx, s.db.Rebind(` + row := s.db.QueryRowContext(ctx, s.db.Rebind(` SELECT tsw.worktree_id, tsw.session_id, @@ -250,43 +250,8 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (* LEFT JOIN repositories r ON tsw.repository_id = r.id WHERE s.task_id = ? AND tsw.status = ? ORDER BY tsw.created_at DESC LIMIT 1 - `), taskID, StatusActive).Scan( - &wt.ID, - &wt.SessionID, - &wt.TaskID, - &wt.RepositoryID, - &repositoryPath, - &wt.Path, - &wt.Branch, - &baseBranch, - &wt.Status, - &wt.CreatedAt, - &wt.UpdatedAt, - &mergedAt, - &deletedAt, - ) - - if err == sql.ErrNoRows { - return nil, nil // Not found, return nil without error - } - if err != nil { - return nil, err - } - - if repositoryPath.Valid { - wt.RepositoryPath = repositoryPath.String - } - if baseBranch.Valid { - wt.BaseBranch = baseBranch.String - } - if mergedAt.Valid { - wt.MergedAt = &mergedAt.Time - } - if deletedAt.Valid { - wt.DeletedAt = &deletedAt.Time - } - - return wt, nil + `), taskID, StatusActive) + return scanWorktreeRow(row) } // GetWorktreesByTaskID retrieves all worktrees for a task. diff --git a/apps/backend/pkg/claudecode/types_test.go b/apps/backend/pkg/claudecode/types_test.go index 0a57418e45..753a9cbc2d 100644 --- a/apps/backend/pkg/claudecode/types_test.go +++ b/apps/backend/pkg/claudecode/types_test.go @@ -39,13 +39,14 @@ func TestCLIMessage_GetResultData(t *testing.T) { t.Run(tt.name, func(t *testing.T) { msg := &CLIMessage{Result: tt.result} got := msg.GetResultData() - if tt.wantNil { + switch { + case tt.wantNil: if got != nil { t.Errorf("GetResultData() = %v, want nil", got) } - } else if got == nil { + case got == nil: t.Fatalf("GetResultData() = nil, want non-nil") - } else if got.Text != tt.wantText { + case got.Text != tt.wantText: t.Errorf("GetResultData().Text = %q, want %q", got.Text, tt.wantText) } }) diff --git a/apps/backend/pkg/codex/client.go b/apps/backend/pkg/codex/client.go index 6d49f2a861..2079d1257f 100644 --- a/apps/backend/pkg/codex/client.go +++ b/apps/backend/pkg/codex/client.go @@ -165,37 +165,7 @@ func (c *Client) readLoop(ctx context.Context) { if len(line) == 0 { continue } - - var msg struct { - ID interface{} `json:"id"` - Method string `json:"method"` - Result json.RawMessage `json:"result"` - Error *Error `json:"error"` - Params json.RawMessage `json:"params"` - } - if err := json.Unmarshal(line, &msg); err != nil { - c.logger.Warn("failed to parse message", zap.Error(err)) - continue - } - - hasID := msg.ID != nil - hasMethod := msg.Method != "" - hasResult := msg.Result != nil - hasError := msg.Error != nil - - c.logger.Info("codex: received message", - zap.Bool("hasID", hasID), - zap.Bool("hasMethod", hasMethod), - zap.String("method", msg.Method)) - - if hasID && !hasMethod && (hasResult || hasError) { - c.handleResponse(&Response{ID: msg.ID, Result: msg.Result, Error: msg.Error}) - } else if hasID && hasMethod { - c.logger.Info("codex: routing as request", zap.Any("id", msg.ID), zap.String("method", msg.Method)) - c.handleRequest(msg.ID, msg.Method, msg.Params) - } else if hasMethod && !hasID { - c.handleNotification(msg.Method, msg.Params) - } + c.processCodexMessage(line) } if err := scanner.Err(); err != nil { @@ -203,6 +173,41 @@ func (c *Client) readLoop(ctx context.Context) { } } +// processCodexMessage parses a single JSON line and routes it as a response, request, or notification. +func (c *Client) processCodexMessage(line []byte) { + var msg struct { + ID interface{} `json:"id"` + Method string `json:"method"` + Result json.RawMessage `json:"result"` + Error *Error `json:"error"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(line, &msg); err != nil { + c.logger.Warn("failed to parse message", zap.Error(err)) + return + } + + hasID := msg.ID != nil + hasMethod := msg.Method != "" + hasResult := msg.Result != nil + hasError := msg.Error != nil + + c.logger.Info("codex: received message", + zap.Bool("hasID", hasID), + zap.Bool("hasMethod", hasMethod), + zap.String("method", msg.Method)) + + switch { + case hasID && !hasMethod && (hasResult || hasError): + c.handleResponse(&Response{ID: msg.ID, Result: msg.Result, Error: msg.Error}) + case hasID && hasMethod: + c.logger.Info("codex: routing as request", zap.Any("id", msg.ID), zap.String("method", msg.Method)) + c.handleRequest(msg.ID, msg.Method, msg.Params) + case hasMethod && !hasID: + c.handleNotification(msg.Method, msg.Params) + } +} + func (c *Client) handleResponse(resp *Response) { id := normalizeID(resp.ID) c.mu.Lock() diff --git a/apps/backend/pkg/opencode/client_test.go b/apps/backend/pkg/opencode/client_test.go index 970c825c3b..abeae2fb01 100644 --- a/apps/backend/pkg/opencode/client_test.go +++ b/apps/backend/pkg/opencode/client_test.go @@ -246,10 +246,8 @@ func TestClient_SendPrompt_WithModel(t *testing.T) { if receivedBody.Model == nil { t.Error("expected model to be set") - } else { - if receivedBody.Model.ProviderID != "anthropic" { - t.Errorf("expected providerID 'anthropic', got %s", receivedBody.Model.ProviderID) - } + } else if receivedBody.Model.ProviderID != "anthropic" { + t.Errorf("expected providerID 'anthropic', got %s", receivedBody.Model.ProviderID) } if receivedBody.Agent != "coder" { t.Errorf("expected agent 'coder', got %s", receivedBody.Agent) diff --git a/apps/backend/pkg/opencode/types_test.go b/apps/backend/pkg/opencode/types_test.go index 729dc92286..c6c6125455 100644 --- a/apps/backend/pkg/opencode/types_test.go +++ b/apps/backend/pkg/opencode/types_test.go @@ -83,10 +83,8 @@ func TestParseMessageUpdated(t *testing.T) { } if props.Info.Model == nil { t.Error("expected model to be set") - } else { - if props.Info.Model.ProviderID != "anthropic" { - t.Errorf("expected providerID 'anthropic', got %s", props.Info.Model.ProviderID) - } + } else if props.Info.Model.ProviderID != "anthropic" { + t.Errorf("expected providerID 'anthropic', got %s", props.Info.Model.ProviderID) } if props.Info.Tokens == nil { t.Error("expected tokens to be set")