From a23ab5be3a22306597252364a42a29214aa27132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 21 Feb 2026 22:14:56 +0000 Subject: [PATCH] feat: remote executors + secrets (#257) --- AGENTS.md | 2 +- Makefile | 4 + apps/backend/Makefile | 16 +- apps/backend/cmd/kandev/adapters.go | 2 + apps/backend/cmd/kandev/agents.go | 41 +- apps/backend/cmd/kandev/helpers.go | 19 + apps/backend/cmd/kandev/main.go | 16 +- apps/backend/cmd/kandev/orchestrator.go | 4 +- apps/backend/cmd/kandev/storage.go | 19 + apps/backend/cmd/kandev/types.go | 2 + apps/backend/go.mod | 3 + apps/backend/go.sum | 6 + apps/backend/internal/agent/docker/client.go | 51 + .../backend/internal/agent/docker/handlers.go | 221 ++ .../runtime.go => executor/executor.go} | 13 +- .../agent/handlers/shell_handlers_test.go | 2 +- .../agent/lifecycle/agentctl_resolver.go | 64 + .../internal/agent/lifecycle/env_preparer.go | 88 + .../agent/lifecycle/env_preparer_docker.go | 44 + .../agent/lifecycle/env_preparer_local.go | 136 + .../agent/lifecycle/env_preparer_sprites.go | 47 + .../agent/lifecycle/env_preparer_worktree.go | 100 + .../internal/agent/lifecycle/event_types.go | 36 + .../internal/agent/lifecycle/events.go | 32 + .../{runtime.go => executor_backend.go} | 35 +- .../{runtime_docker.go => executor_docker.go} | 34 +- ...ntime_registry.go => executor_registry.go} | 62 +- ...stry_test.go => executor_registry_test.go} | 160 +- ...te_docker.go => executor_remote_docker.go} | 28 +- .../agent/lifecycle/executor_sprites.go | 596 +++++ .../lifecycle/executor_sprites_e2e_test.go | 422 ++++ ...e_standalone.go => executor_standalone.go} | 32 +- .../internal/agent/lifecycle/manager.go | 74 +- .../agent/lifecycle/manager_events_test.go | 2 +- .../agent/lifecycle/manager_execution.go | 4 +- ...manager_runtime.go => manager_executor.go} | 32 +- .../agent/lifecycle/manager_interaction.go | 16 +- .../agent/lifecycle/manager_launch.go | 105 +- .../agent/lifecycle/manager_lifecycle.go | 50 +- .../agent/lifecycle/manager_passthrough.go | 6 +- .../agent/lifecycle/manager_profile.go | 8 +- .../agent/lifecycle/manager_startup.go | 4 +- .../internal/agent/lifecycle/manager_test.go | 2 +- .../backend/internal/agent/lifecycle/types.go | 3 + .../internal/agent/mcpconfig/policy.go | 6 +- .../internal/agentctl/tracing/executor.go | 87 + apps/backend/internal/events/types.go | 13 + .../gateway/websocket/task_notifications.go | 10 + .../internal/integration/test_server_test.go | 2 +- .../orchestrator/executor/executor.go | 9 +- .../orchestrator/executor/executor_execute.go | 41 +- .../orchestrator/executor/executor_state.go | 62 +- .../orchestrator/executor/executor_test.go | 24 +- .../orchestrator/executor/script_template.go | 10 + .../orchestrator/handlers/handlers.go | 17 +- apps/backend/internal/orchestrator/service.go | 5 +- .../internal/orchestrator/task_operations.go | 11 +- .../backend/internal/scriptengine/defaults.go | 89 + .../internal/scriptengine/placeholders.go | 73 + .../internal/scriptengine/providers.go | 106 + .../backend/internal/scriptengine/resolver.go | 77 + apps/backend/internal/secrets/crypto.go | 107 + apps/backend/internal/secrets/handlers.go | 190 ++ apps/backend/internal/secrets/models.go | 43 + apps/backend/internal/secrets/provider.go | 51 + apps/backend/internal/secrets/service.go | 119 + apps/backend/internal/secrets/sqlite_store.go | 209 ++ apps/backend/internal/secrets/store.go | 28 + apps/backend/internal/sprites/handlers.go | 477 ++++ apps/backend/internal/task/dto/dto.go | 47 + .../task/handlers/executor_handlers.go | 9 +- .../handlers/executor_profile_handlers.go | 354 +++ .../task/handlers/process_handlers_test.go | 28 +- .../task/handlers/script_placeholders.go | 11 + .../internal/task/handlers/task_handlers.go | 10 +- .../task/handlers/task_http_handlers.go | 37 +- .../task/handlers/task_ws_handlers.go | 31 +- apps/backend/internal/task/models/models.go | 52 +- .../internal/task/repository/interface.go | 8 + .../internal/task/repository/sqlite/base.go | 36 + .../task/repository/sqlite/defaults.go | 33 + .../repository/sqlite/executor_profile.go | 166 ++ .../task/repository/sqlite/session.go | 28 +- .../internal/task/service/default_scripts.go | 13 + .../internal/task/service/service_events.go | 18 + .../internal/task/service/service_requests.go | 21 + .../task/service/service_resources.go | 83 + apps/backend/pkg/websocket/actions.go | 28 + apps/pnpm-lock.yaml | 77 +- apps/web/app/actions/environments.ts | 69 - apps/web/app/globals.css | 2 +- apps/web/app/s/[sessionId]/page.tsx | 1 - .../agents/[agentId]/agent-setup-parts.tsx | 7 +- .../app/settings/agents/[agentId]/page.tsx | 8 +- .../app/settings/environment/[id]/page.tsx | 372 --- .../web/app/settings/environment/new/page.tsx | 274 --- apps/web/app/settings/environments/page.tsx | 40 - apps/web/app/settings/executor/[id]/page.tsx | 462 ++-- .../[id]/profile/[profileId]/page.tsx | 527 ++++ .../settings/executors/[profileId]/page.tsx | 445 ++++ .../settings/executors/new/[type]/page.tsx | 346 +++ apps/web/app/settings/executors/page.tsx | 338 ++- .../web/app/settings/general/secrets/page.tsx | 25 + .../web/app/settings/general/sprites/page.tsx | 29 + apps/web/app/settings/layout.tsx | 8 +- .../workspace/workspace-edit-client.tsx | 42 +- .../workspace-repositories-client.tsx | 3 +- .../workspace/workspace-workflows-client.tsx | 176 +- apps/web/app/tasks/tasks-page-client.tsx | 148 +- apps/web/components/command-panel-footer.tsx | 359 +++ apps/web/components/command-panel.tsx | 533 ++-- .../components/diff/use-diff-viewer-state.ts | 9 +- .../editors/monaco/diff-viewer-toolbar.tsx | 92 +- .../editors/monaco/monaco-code-editor.tsx | 107 +- .../editors/monaco/monaco-diff-viewer.tsx | 73 +- apps/web/components/kanban-board.tsx | 118 +- .../kanban/swimlane-graph-content.tsx | 4 +- apps/web/components/onboarding-dialog.tsx | 10 +- apps/web/components/review/review-dialog.tsx | 172 +- .../components/session/prepare-progress.tsx | 66 + .../components/settings/editors-settings.tsx | 5 +- .../components/settings/environment-card.tsx | 51 - .../settings/executor-profile-dialog.tsx | 95 + .../settings/executor-profiles-card.tsx | 125 + .../notifications-settings-actions.ts | 4 +- .../settings/profile-edit/docker-sections.tsx | 292 +++ .../settings/profile-edit/env-vars-card.tsx | 170 ++ .../settings/profile-edit/mcp-policy-card.tsx | 127 + .../profile-edit/profile-details-card.tsx | 26 + .../settings/profile-edit/script-card.tsx | 45 + .../profile-edit/script-editor-completions.ts | 63 + .../settings/profile-edit/script-editor.tsx | 90 + .../profile-edit/sprites-api-key-card.tsx | 203 ++ .../profile-edit/sprites-sections.tsx | 162 ++ .../components/settings/secrets-settings.tsx | 440 ++++ .../settings/settings-app-sidebar.tsx | 112 +- .../components/settings/sprites-settings.tsx | 334 +++ .../settings/workflow-card-actions.ts | 86 +- .../settings/workflow-card-dialogs.tsx | 211 ++ .../web/components/settings/workflow-card.tsx | 488 ++-- .../task-create-dialog-form-body.tsx | 60 +- .../components/task-create-dialog-helpers.ts | 51 + .../components/task-create-dialog-options.tsx | 42 +- .../task-create-dialog-selectors.tsx | 33 + .../components/task-create-dialog-state.ts | 487 ++-- .../components/task-create-dialog-submit.tsx | 31 +- .../components/task-create-dialog-types.ts | 123 + apps/web/components/task-create-dialog.tsx | 349 ++- .../components/task/changes-panel-hooks.ts | 238 ++ apps/web/components/task/changes-panel.tsx | 501 ++-- apps/web/components/task/changes-top-bar.tsx | 199 ++ .../web/components/task/chat-context-items.ts | 20 +- .../components/task/chat/chat-input-area.tsx | 260 ++ .../task/chat/chat-input-container.tsx | 42 +- .../task/chat/clarification-input-overlay.tsx | 4 +- .../components/task/chat/context-popover.tsx | 10 +- .../task/chat/messages/rich-blocks.tsx | 29 +- .../web/components/task/chat/tiptap-input.tsx | 94 +- .../task/chat/use-chat-input-container.ts | 115 +- .../task/chat/use-chat-panel-state.ts | 332 +++ .../components/task/chat/use-tiptap-editor.ts | 90 +- .../task/chat/virtualized-message-list.tsx | 6 +- .../task/dockview-header-actions.tsx | 5 +- .../web/components/task/file-browser-hooks.ts | 471 ++++ apps/web/components/task/file-browser.tsx | 510 +--- .../mobile/session-task-switcher-sheet.tsx | 122 +- .../components/task/passthrough-terminal.tsx | 148 +- .../task/plan-selection-popover.tsx | 3 +- .../components/task/preview/preview-panel.tsx | 117 +- .../web/components/task/sessions-dropdown.tsx | 10 +- .../task/task-center-panel-restoration.ts | 33 +- .../web/components/task/task-center-panel.tsx | 92 +- .../components/task/task-changes-panel.tsx | 194 +- apps/web/components/task/task-chat-panel.tsx | 481 +--- apps/web/components/task/task-files-panel.tsx | 9 +- .../hooks/domains/session/use-terminals.ts | 37 +- .../web/hooks/domains/settings/use-secrets.ts | 31 + .../domains/settings/use-settings-data.ts | 23 +- .../web/hooks/domains/settings/use-sprites.ts | 38 + apps/web/hooks/use-drag-and-drop.ts | 13 +- apps/web/hooks/use-file-editors.ts | 109 +- apps/web/hooks/use-git-operations.ts | 6 +- apps/web/hooks/use-gutter-comments.ts | 76 +- apps/web/hooks/use-inline-mention.ts | 9 +- apps/web/hooks/use-open-session-in-editor.ts | 4 +- apps/web/hooks/use-preview-panel.ts | 107 +- apps/web/lib/api/domains/kanban-api.ts | 1 + apps/web/lib/api/domains/secrets-api.ts | 49 + apps/web/lib/api/domains/settings-api.ts | 201 +- apps/web/lib/api/domains/sprites-api.ts | 59 + apps/web/lib/api/domains/vscode-api.ts | 9 +- apps/web/lib/executor-icons.ts | 10 +- apps/web/lib/state/dockview-store.ts | 40 +- apps/web/lib/state/hydration/hydrator.ts | 1 - apps/web/lib/state/slices/index.ts | 5 +- .../session-runtime/session-runtime-slice.ts | 1 + .../lib/state/slices/session-runtime/types.ts | 20 + .../state/slices/settings/settings-slice.ts | 236 +- apps/web/lib/state/slices/settings/types.ts | 34 +- apps/web/lib/state/store.ts | 27 +- apps/web/lib/types/backend.ts | 56 + apps/web/lib/types/http-secrets.ts | 21 + apps/web/lib/types/http-sprites.ts | 28 + apps/web/lib/types/http.ts | 27 + apps/web/lib/utils.ts | 1 - apps/web/lib/ws/handlers/environments.ts | 51 - apps/web/lib/ws/handlers/executor-prepare.ts | 68 + apps/web/lib/ws/handlers/executor-profiles.ts | 65 + apps/web/lib/ws/handlers/secrets.ts | 21 + apps/web/lib/ws/handlers/session-mode.ts | 8 +- apps/web/lib/ws/router.ts | 8 +- apps/web/package.json | 1 - docs/backend_agentctl_connectivity.md | 242 ++ docs/plans/01-secrets-infrastructure.md | 915 +++++++ .../02-executor-profiles-infrastructure.md | 2191 +++++++++++++++++ docs/plans/03-sprites-dev-support.md | 1336 ++++++++++ docs/plans/04-executors.md | 63 + 217 files changed, 20155 insertions(+), 4997 deletions(-) create mode 100644 apps/backend/internal/agent/docker/handlers.go rename apps/backend/internal/agent/{runtime/runtime.go => executor/executor.go} (58%) create mode 100644 apps/backend/internal/agent/lifecycle/agentctl_resolver.go create mode 100644 apps/backend/internal/agent/lifecycle/env_preparer.go create mode 100644 apps/backend/internal/agent/lifecycle/env_preparer_docker.go create mode 100644 apps/backend/internal/agent/lifecycle/env_preparer_local.go create mode 100644 apps/backend/internal/agent/lifecycle/env_preparer_sprites.go create mode 100644 apps/backend/internal/agent/lifecycle/env_preparer_worktree.go rename apps/backend/internal/agent/lifecycle/{runtime.go => executor_backend.go} (76%) rename apps/backend/internal/agent/lifecycle/{runtime_docker.go => executor_docker.go} (84%) rename apps/backend/internal/agent/lifecycle/{runtime_registry.go => executor_registry.go} (57%) rename apps/backend/internal/agent/lifecycle/{runtime_registry_test.go => executor_registry_test.go} (57%) rename apps/backend/internal/agent/lifecycle/{runtime_remote_docker.go => executor_remote_docker.go} (63%) create mode 100644 apps/backend/internal/agent/lifecycle/executor_sprites.go create mode 100644 apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go rename apps/backend/internal/agent/lifecycle/{runtime_standalone.go => executor_standalone.go} (80%) rename apps/backend/internal/agent/lifecycle/{manager_runtime.go => manager_executor.go} (57%) create mode 100644 apps/backend/internal/agentctl/tracing/executor.go create mode 100644 apps/backend/internal/orchestrator/executor/script_template.go create mode 100644 apps/backend/internal/scriptengine/defaults.go create mode 100644 apps/backend/internal/scriptengine/placeholders.go create mode 100644 apps/backend/internal/scriptengine/providers.go create mode 100644 apps/backend/internal/scriptengine/resolver.go create mode 100644 apps/backend/internal/secrets/crypto.go create mode 100644 apps/backend/internal/secrets/handlers.go create mode 100644 apps/backend/internal/secrets/models.go create mode 100644 apps/backend/internal/secrets/provider.go create mode 100644 apps/backend/internal/secrets/service.go create mode 100644 apps/backend/internal/secrets/sqlite_store.go create mode 100644 apps/backend/internal/secrets/store.go create mode 100644 apps/backend/internal/sprites/handlers.go create mode 100644 apps/backend/internal/task/handlers/executor_profile_handlers.go create mode 100644 apps/backend/internal/task/handlers/script_placeholders.go create mode 100644 apps/backend/internal/task/repository/sqlite/executor_profile.go create mode 100644 apps/backend/internal/task/service/default_scripts.go delete mode 100644 apps/web/app/actions/environments.ts delete mode 100644 apps/web/app/settings/environment/[id]/page.tsx delete mode 100644 apps/web/app/settings/environment/new/page.tsx delete mode 100644 apps/web/app/settings/environments/page.tsx create mode 100644 apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx create mode 100644 apps/web/app/settings/executors/[profileId]/page.tsx create mode 100644 apps/web/app/settings/executors/new/[type]/page.tsx create mode 100644 apps/web/app/settings/general/secrets/page.tsx create mode 100644 apps/web/app/settings/general/sprites/page.tsx create mode 100644 apps/web/components/command-panel-footer.tsx create mode 100644 apps/web/components/session/prepare-progress.tsx delete mode 100644 apps/web/components/settings/environment-card.tsx create mode 100644 apps/web/components/settings/executor-profile-dialog.tsx create mode 100644 apps/web/components/settings/executor-profiles-card.tsx create mode 100644 apps/web/components/settings/profile-edit/docker-sections.tsx create mode 100644 apps/web/components/settings/profile-edit/env-vars-card.tsx create mode 100644 apps/web/components/settings/profile-edit/mcp-policy-card.tsx create mode 100644 apps/web/components/settings/profile-edit/profile-details-card.tsx create mode 100644 apps/web/components/settings/profile-edit/script-card.tsx create mode 100644 apps/web/components/settings/profile-edit/script-editor-completions.ts create mode 100644 apps/web/components/settings/profile-edit/script-editor.tsx create mode 100644 apps/web/components/settings/profile-edit/sprites-api-key-card.tsx create mode 100644 apps/web/components/settings/profile-edit/sprites-sections.tsx create mode 100644 apps/web/components/settings/secrets-settings.tsx create mode 100644 apps/web/components/settings/sprites-settings.tsx create mode 100644 apps/web/components/settings/workflow-card-dialogs.tsx create mode 100644 apps/web/components/task-create-dialog-helpers.ts create mode 100644 apps/web/components/task-create-dialog-types.ts create mode 100644 apps/web/components/task/changes-panel-hooks.ts create mode 100644 apps/web/components/task/changes-top-bar.tsx create mode 100644 apps/web/components/task/chat/chat-input-area.tsx create mode 100644 apps/web/components/task/chat/use-chat-panel-state.ts create mode 100644 apps/web/components/task/file-browser-hooks.ts create mode 100644 apps/web/hooks/domains/settings/use-secrets.ts create mode 100644 apps/web/hooks/domains/settings/use-sprites.ts create mode 100644 apps/web/lib/api/domains/secrets-api.ts create mode 100644 apps/web/lib/api/domains/sprites-api.ts create mode 100644 apps/web/lib/types/http-secrets.ts create mode 100644 apps/web/lib/types/http-sprites.ts delete mode 100644 apps/web/lib/ws/handlers/environments.ts create mode 100644 apps/web/lib/ws/handlers/executor-prepare.ts create mode 100644 apps/web/lib/ws/handlers/executor-profiles.ts create mode 100644 apps/web/lib/ws/handlers/secrets.ts create mode 100644 docs/backend_agentctl_connectivity.md create mode 100644 docs/plans/01-secrets-infrastructure.md create mode 100644 docs/plans/02-executor-profiles-infrastructure.md create mode 100644 docs/plans/03-sprites-dev-support.md create mode 100644 docs/plans/04-executors.md diff --git a/AGENTS.md b/AGENTS.md index 676a321ea7..d16d253581 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,7 +305,7 @@ Static analysis runs in CI and pre-commit. New code **must** stay within these l - **Interactivity:** All buttons and links with actions must have `cursor-pointer` class ### Plan Implementation -- After implementing a plan, run `make typecheck test lint` to verify the changes +- After implementing a plan, run `make fmt` first to format code, then run `make typecheck test lint` to verify the changes. Formatting must come first because formatters may split lines, which can trigger complexity linter warnings. --- diff --git a/Makefile b/Makefile index 87fec7b7b7..676ccc013b 100644 --- a/Makefile +++ b/Makefile @@ -193,6 +193,10 @@ install-web: test: test-backend test-web @printf "\n$(GREEN)$(BOLD)✓ All tests complete!$(RESET)\n" +.PHONY: test-sprites-e2e +test-sprites-e2e: + @$(MAKE) -C $(BACKEND_DIR) test-sprites-e2e + .PHONY: test-backend test-backend: @printf "$(CYAN)Running backend tests...$(RESET)\n" diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 8d996403fe..252c3f9de8 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -25,7 +25,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build build-all build-agentctl build-mock-agent clean run dev test test-e2e lint fmt vet help +.PHONY: all build build-all build-agentctl build-agentctl-linux build-mock-agent clean run dev test test-e2e test-sprites-e2e lint fmt vet help ## Default target all: build @@ -53,6 +53,13 @@ build-agentctl: @$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl ./cmd/agentctl +## Build agentctl for linux/amd64 (used by Sprites remote executor) +build-agentctl-linux: + @echo "Building agentctl for linux/amd64..." + @$(MKDIR) $(BUILD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-linux-amd64 ./cmd/agentctl + + ## Clean build artifacts clean: @echo "Cleaning..." @@ -81,6 +88,13 @@ test-e2e: @echo "Running E2E adapter tests..." $(GO) test -tags e2e -v -timeout 10m ./internal/agentctl/server/adapter/e2e/ +## Run sprites e2e test (requires SPRITES_API_TOKEN env var) +## Sprites VMs are always x86_64/amd64 regardless of host architecture. +test-sprites-e2e: build-agentctl-linux + @echo "Running sprites e2e test..." + KANDEV_AGENTCTL_LINUX_BINARY=$(CURDIR)/$(BUILD_DIR)/agentctl-linux-amd64 \ + $(GO) test -v -count=1 -tags sprites_e2e -run TestSpritesE2E -timeout 10m ./internal/agent/lifecycle/ + ## Run tests with coverage test-coverage: @echo "Running tests with coverage..." diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 8eddb6e9de..2b64ce1526 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -76,6 +76,8 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch Metadata: req.Metadata, ModelOverride: req.ModelOverride, ExecutorType: req.ExecutorType, + ExecutorConfig: req.ExecutorConfig, + SetupScript: req.SetupScript, // Worktree configuration for concurrent agent execution UseWorktree: req.UseWorktree, RepositoryID: req.RepositoryID, diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index d318edc737..c1b369bb6b 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -8,6 +8,7 @@ import ( "github.com/kandev/kandev/internal/agent/credentials" "github.com/kandev/kandev/internal/agent/docker" + agentexecutor "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" @@ -17,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/secrets" ) func provideLifecycleManager( @@ -27,11 +29,12 @@ func provideLifecycleManager( dockerClient *docker.Client, agentSettingsRepo settingsstore.Repository, agentRegistry *registry.Registry, + secretStore secrets.SecretStore, ) (*lifecycle.Manager, error) { log.Info("Initializing Agent Manager...") // Create runtime registry to manage multiple runtimes - runtimeRegistry := lifecycle.NewRuntimeRegistry(log) + executorRegistry := lifecycle.NewExecutorRegistry(log) // Standalone runtime is always available (agentctl is a core service) controlClient := agentctl.NewControlClient( @@ -39,7 +42,7 @@ func provideLifecycleManager( cfg.Agent.StandalonePort, log, ) - standaloneRuntime := lifecycle.NewStandaloneRuntime( + standaloneExec := lifecycle.NewStandaloneExecutor( controlClient, cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, @@ -48,9 +51,9 @@ func provideLifecycleManager( // Create InteractiveRunner for passthrough mode (no WorkspaceTracker, uses callbacks) interactiveRunner := process.NewInteractiveRunner(nil, log, 2*1024*1024) // 2MB buffer - standaloneRuntime.SetInteractiveRunner(interactiveRunner) + standaloneExec.SetInteractiveRunner(interactiveRunner) - runtimeRegistry.Register(standaloneRuntime) + executorRegistry.Register(standaloneExec) log.Info("Standalone runtime registered with passthrough support", zap.String("host", cfg.Agent.StandaloneHost), zap.Int("port", cfg.Agent.StandalonePort)) @@ -59,19 +62,28 @@ func provideLifecycleManager( var containerMgr *lifecycle.ContainerManager if cfg.Docker.Enabled && dockerClient != nil { containerMgr = lifecycle.NewContainerManager(dockerClient, "", log) - dockerRuntime := lifecycle.NewDockerRuntime(dockerClient, log) - runtimeRegistry.Register(dockerRuntime) + dockerExec := lifecycle.NewDockerExecutor(dockerClient, log) + executorRegistry.Register(dockerExec) log.Info("Docker runtime registered") } else if cfg.Docker.Enabled && dockerClient == nil { log.Warn("Docker runtime enabled but Docker client not available") } // Register Remote Docker runtime (always available, instances are created lazily per host) - remoteDockerRuntime := lifecycle.NewRemoteDockerRuntime(log) - runtimeRegistry.Register(remoteDockerRuntime) + remoteDockerExec := lifecycle.NewRemoteDockerExecutor(log) + executorRegistry.Register(remoteDockerExec) log.Info("Remote Docker runtime registered") + // Register Sprites runtime (remote sandboxes via Sprites.dev) + agentctlResolver := lifecycle.NewAgentctlResolver(log) + spritesExec := lifecycle.NewSpritesExecutor(secretStore, agentctlResolver, 8765, log) + executorRegistry.Register(spritesExec) + log.Info("Sprites runtime registered") + credsMgr := credentials.NewManager(log) + if secretStore != nil { + credsMgr.AddProvider(secrets.NewSecretStoreProvider(secretStore)) + } credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_")) credsMgr.AddProvider(credentials.NewAugmentSessionProvider()) if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" { @@ -84,15 +96,22 @@ func provideLifecycleManager( lifecycleMgr := lifecycle.NewManager( agentRegistry, eventBus, - runtimeRegistry, + executorRegistry, containerMgr, credsMgr, profileResolver, mcpService, - lifecycle.RuntimeFallbackWarn, + lifecycle.ExecutorFallbackWarn, log, ) + // Register environment preparers + preparerRegistry := lifecycle.NewPreparerRegistry(log) + preparerRegistry.Register(agentexecutor.NameStandalone, lifecycle.NewLocalPreparer(log)) + preparerRegistry.Register(agentexecutor.NameDocker, lifecycle.NewDockerPreparer(log)) + preparerRegistry.Register(agentexecutor.NameSprites, lifecycle.NewSpritesPreparer(log)) + lifecycleMgr.SetPreparerRegistry(preparerRegistry) + // MCP handler is set later in main.go after MCP handlers are registered // via lifecycleMgr.SetMCPHandler(gateway.Dispatcher) @@ -101,7 +120,7 @@ func provideLifecycleManager( } log.Info("Agent Manager initialized", - zap.Int("runtimes", len(runtimeRegistry.List())), + zap.Int("runtimes", len(executorRegistry.List())), zap.Int("agent_types", len(agentRegistry.List()))) return lifecycleMgr, nil } diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 8cc48096a1..e899594cf2 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -29,6 +29,8 @@ import ( "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/secrets" + spriteshandlers "github.com/kandev/kandev/internal/sprites" taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" @@ -247,6 +249,7 @@ func newSessionStateChangedHandler(gateway *gateways.Gateway, log *logger.Logger type routeParams struct { router *gin.Engine gateway *gateways.Gateway + dockerClient *docker.Client taskSvc *taskservice.Service taskRepo repository.Repository analyticsRepo analyticsrepository.Repository @@ -260,6 +263,8 @@ type routeParams struct { editorCtrl *editorcontroller.Controller promptCtrl *promptcontroller.Controller msgCreator *messageCreatorAdapter + secretsSvc *secrets.Service + secretStore secrets.SecretStore log *logger.Logger } @@ -285,6 +290,7 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService) { taskhandlers.RegisterTaskRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.orchestratorSvc, p.taskRepo, planService, p.log) taskhandlers.RegisterRepositoryRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterExecutorRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) + taskhandlers.RegisterExecutorProfileRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterEnvironmentRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterMessageRoutes( p.router, p.gateway.Dispatcher, p.taskSvc, @@ -325,6 +331,19 @@ func registerSecondaryRoutes( clarification.RegisterRoutes(p.router, clarificationStore, p.gateway.Hub, p.msgCreator, p.taskRepo, p.log) p.log.Debug("Registered Clarification handlers (HTTP)") + if p.secretsSvc != nil { + secrets.RegisterRoutes(p.router, p.gateway.Dispatcher, p.secretsSvc, p.log) + p.log.Debug("Registered Secrets handlers (HTTP + WebSocket)") + } + + if p.secretStore != nil { + spriteshandlers.RegisterRoutes(p.router, p.gateway.Dispatcher, p.secretStore, p.log) + p.log.Debug("Registered Sprites handlers (HTTP + WebSocket)") + } + + docker.RegisterDockerRoutes(p.router, p.dockerClient, p.log) + p.log.Debug("Registered Docker management handlers (HTTP)") + registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService) } diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index dff445e80b..fb6a0d9043 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -41,6 +41,9 @@ import ( // Orchestrator "github.com/kandev/kandev/internal/orchestrator" + // Secrets + "github.com/kandev/kandev/internal/secrets" + // Database "github.com/kandev/kandev/internal/db" ) @@ -253,7 +256,7 @@ func startAgentInfrastructure( // ============================================ // AGENT MANAGER // ============================================ - lifecycleMgr, err := provideLifecycleManager(ctx, cfg, log, eventBus, dockerClient, repos.AgentSettings, agentRegistry) + lifecycleMgr, err := provideLifecycleManager(ctx, cfg, log, eventBus, dockerClient, repos.AgentSettings, agentRegistry, repos.Secrets) if err != nil { log.Error("Failed to initialize agent manager", zap.Error(err)) return false @@ -283,13 +286,13 @@ func startAgentInfrastructure( log.Info("Initializing Orchestrator...") orchestratorSvc, msgCreator, err := provideOrchestrator(log, eventBus, repos.Task, services.Task, services.User, - lifecycleMgr, agentRegistry, services.Workflow, worktreeRecreator) + lifecycleMgr, agentRegistry, services.Workflow, worktreeRecreator, repos.Secrets) if err != nil { log.Error("Failed to initialize orchestrator", zap.Error(err)) return false } - return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, + return startGatewayAndServe(ctx, cfg, log, eventBus, dockerClient, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, runCleanups) } @@ -300,6 +303,7 @@ func startGatewayAndServe( cfg *config.Config, log *logger.Logger, eventBus bus.EventBus, + dockerClient *docker.Client, repos *Repositories, services *Services, agentSettingsController *agentsettingscontroller.Controller, @@ -341,7 +345,7 @@ func startGatewayAndServe( // ============================================ // HTTP SERVER // ============================================ - server := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, + server := buildHTTPServer(cfg, log, gateway, dockerClient, repos, services, agentSettingsController, lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator) port := cfg.Server.Port @@ -370,6 +374,7 @@ func buildHTTPServer( cfg *config.Config, log *logger.Logger, gateway *gateways.Gateway, + dockerClient *docker.Client, repos *Repositories, services *Services, agentSettingsController *agentsettingscontroller.Controller, @@ -390,6 +395,7 @@ func buildHTTPServer( registerRoutes(routeParams{ router: router, gateway: gateway, + dockerClient: dockerClient, taskSvc: services.Task, taskRepo: repos.Task, analyticsRepo: repos.Analytics, @@ -403,6 +409,8 @@ func buildHTTPServer( editorCtrl: editorcontroller.NewController(services.Editor), promptCtrl: promptcontroller.NewController(services.Prompts), msgCreator: msgCreator, + secretsSvc: secrets.NewService(repos.Secrets, log), + secretStore: repos.Secrets, log: log, }) diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index 794dac3718..11c4d05f34 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -27,6 +28,7 @@ func provideOrchestrator( agentRegistry *registry.Registry, workflowSvc *workflowservice.Service, worktreeRecreator *worktree.Recreator, + secretStore secrets.SecretStore, ) (*orchestrator.Service, *messageCreatorAdapter, error) { if lifecycleMgr == nil { return nil, nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)") @@ -36,7 +38,7 @@ func provideOrchestrator( agentManagerClient := newLifecycleAdapter(lifecycleMgr, agentRegistry, log) serviceCfg := orchestrator.DefaultServiceConfig() - orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, log) + orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, secretStore, log) taskSvc.SetExecutionStopper(orchestratorSvc) msgCreator := &messageCreatorAdapter{svc: taskSvc, logger: log} diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index 841072425a..9764d9c83e 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -1,11 +1,16 @@ package main import ( + "fmt" + "os" + "path/filepath" + analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/persistence" + "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/repository" workflowrepository "github.com/kandev/kandev/internal/workflow/repository" @@ -76,6 +81,19 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep } cleanups = append(cleanups, cleanup) + // Initialize master key and secrets store + kandevDir := filepath.Join(os.Getenv("HOME"), ".kandev") + masterKeyProvider, err := secrets.NewMasterKeyProvider(kandevDir) + if err != nil { + return nil, nil, nil, fmt.Errorf("master key: %w", err) + } + + secretStore, cleanup, err := secrets.Provide(writer, reader, masterKeyProvider) + if err != nil { + return nil, nil, nil, fmt.Errorf("secret store: %w", err) + } + cleanups = append(cleanups, cleanup) + repos := &Repositories{ Task: taskRepoImpl, Analytics: analyticsRepo, @@ -85,6 +103,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep Editor: editorRepo, Prompts: promptRepo, Workflow: workflowRepo, + Secrets: secretStore, } return pool, repos, cleanups, nil } diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index d226035778..b1bac5aee7 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -9,6 +9,7 @@ import ( notificationstore "github.com/kandev/kandev/internal/notifications/store" promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" + "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -26,6 +27,7 @@ type Repositories struct { Editor editorstore.Repository Prompts promptstore.Repository Workflow *workflowrepository.Repository + Secrets secrets.SecretStore } type Services struct { diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 4d072db3d6..2cf4905cd6 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -19,6 +19,7 @@ require ( github.com/nats-io/nats.go v1.31.0 github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.11.1 + github.com/superfly/sprites-go v0.0.0-20260206213632-8176adff485b github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 @@ -28,6 +29,7 @@ require ( ) require ( + github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Microsoft/go-winio v0.4.21 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect @@ -99,6 +101,7 @@ require ( golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 21352dcf8c..f39efab2a8 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -42,6 +42,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to= @@ -316,6 +318,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/superfly/sprites-go v0.0.0-20260206213632-8176adff485b h1:m20F4on1i8Mv3nexDOJGB6JhpOG9Q4XepN45yJvd8Lc= +github.com/superfly/sprites-go v0.0.0-20260206213632-8176adff485b/go.mod h1:4zltGIGJa3HV+XumRyNn4BmhlavbUZH3Uh5xJNaDwsY= github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7 h1:U7j2kc5ZS6dmwTg0H2VbRNGUwUxxEv9HtyxsyiYyS84= github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7/go.mod h1:kypKQ2Vd/oUOxC2dX6DFMG6FktzHsrYs+BF35RTA7p8= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -506,6 +510,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/apps/backend/internal/agent/docker/client.go b/apps/backend/internal/agent/docker/client.go index 148d7bb6a2..e18bd4d1fc 100644 --- a/apps/backend/internal/agent/docker/client.go +++ b/apps/backend/internal/agent/docker/client.go @@ -2,6 +2,8 @@ package docker import ( + "archive/tar" + "bytes" "context" "encoding/binary" "fmt" @@ -9,6 +11,7 @@ import ( "net" "time" + "github.com/docker/docker/api/types/build" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" @@ -124,6 +127,54 @@ func (c *Client) PullImage(ctx context.Context, imageName string) error { return nil } +// BuildImage builds a Docker image from a Dockerfile string. +// It returns the build output as an io.ReadCloser for streaming. +// The caller is responsible for closing the returned reader. +func (c *Client) BuildImage(ctx context.Context, dockerfile string, tag string, buildArgs map[string]*string) (io.ReadCloser, error) { + c.logger.Info("Building image", zap.String("tag", tag)) + + buildContext, err := createDockerfileTar(dockerfile) + if err != nil { + return nil, fmt.Errorf("failed to create build context: %w", err) + } + + resp, err := c.cli.ImageBuild(ctx, buildContext, build.ImageBuildOptions{ + Tags: []string{tag}, + BuildArgs: buildArgs, + Dockerfile: "Dockerfile", + Remove: true, + }) + if err != nil { + c.logger.Error("Failed to build image", zap.String("tag", tag), zap.Error(err)) + return nil, fmt.Errorf("failed to build image %s: %w", tag, err) + } + + return resp.Body, nil +} + +// createDockerfileTar creates a tar archive containing a single Dockerfile. +func createDockerfileTar(dockerfile string) (io.Reader, error) { + buf := new(bytes.Buffer) + tw := tar.NewWriter(buf) + + header := &tar.Header{ + Name: "Dockerfile", + Size: int64(len(dockerfile)), + Mode: 0o644, + } + if err := tw.WriteHeader(header); err != nil { + return nil, fmt.Errorf("failed to write tar header: %w", err) + } + if _, err := tw.Write([]byte(dockerfile)); err != nil { + return nil, fmt.Errorf("failed to write Dockerfile to tar: %w", err) + } + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("failed to close tar writer: %w", err) + } + + return buf, nil +} + // CreateContainer creates a new container. func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (string, error) { c.logger.Info("Creating container", diff --git a/apps/backend/internal/agent/docker/handlers.go b/apps/backend/internal/agent/docker/handlers.go new file mode 100644 index 0000000000..a63e1a2c37 --- /dev/null +++ b/apps/backend/internal/agent/docker/handlers.go @@ -0,0 +1,221 @@ +// Package docker provides Docker management HTTP handlers. +package docker + +import ( + "bufio" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// buildImageRequest is the JSON body for POST /api/v1/docker/build. +type buildImageRequest struct { + Dockerfile string `json:"dockerfile" binding:"required"` + Tag string `json:"tag" binding:"required"` + BuildArgs map[string]*string `json:"build_args,omitempty"` +} + +// containerResponse is the JSON representation of a container in list responses. +type containerResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` +} + +// stopContainerRequest is the optional JSON body for POST /api/v1/docker/containers/:id/stop. +type stopContainerRequest struct { + TimeoutSeconds int `json:"timeout_seconds,omitempty"` +} + +// RegisterDockerRoutes registers Docker management HTTP routes on the given router. +func RegisterDockerRoutes(router *gin.Engine, dockerClient *Client, log *logger.Logger) { + if dockerClient == nil { + log.Warn("Docker client is nil, skipping Docker route registration") + return + } + + api := router.Group("/api/v1/docker") + api.POST("/build", handleBuildImage(dockerClient, log)) + api.GET("/containers", handleListContainers(dockerClient, log)) + api.POST("/containers/:id/stop", handleStopContainer(dockerClient, log)) + api.DELETE("/containers/:id", handleRemoveContainer(dockerClient, log)) +} + +// handleBuildImage handles POST /api/v1/docker/build. +// It streams the Docker build output as JSON lines to the client. +func handleBuildImage(dockerClient *Client, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + var req buildImageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()}) + return + } + + reader, err := dockerClient.BuildImage(c.Request.Context(), req.Dockerfile, req.Tag, req.BuildArgs) + if err != nil { + log.Error("Failed to start image build", zap.String("tag", req.Tag), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer func() { + if closeErr := reader.Close(); closeErr != nil { + log.Warn("Failed to close build reader", zap.Error(closeErr)) + } + }() + + streamBuildOutput(c, reader, log) + } +} + +// streamBuildOutput reads from the Docker build output and streams it to the HTTP response. +func streamBuildOutput(c *gin.Context, reader interface{ Read([]byte) (int, error) }, log *logger.Logger) { + c.Header("Content-Type", "application/x-ndjson") + c.Status(http.StatusOK) + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Bytes() + if _, err := c.Writer.Write(line); err != nil { + log.Debug("Client disconnected during build stream", zap.Error(err)) + return + } + if _, err := c.Writer.Write([]byte("\n")); err != nil { + log.Debug("Client disconnected during build stream", zap.Error(err)) + return + } + c.Writer.Flush() + } + + if err := scanner.Err(); err != nil { + log.Error("Error reading build output", zap.Error(err)) + } +} + +// handleListContainers handles GET /api/v1/docker/containers. +// Supports optional query params: image, labels (comma-separated key=value pairs). +func handleListContainers(dockerClient *Client, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + labels := parseLabelsQuery(c) + addImageFilter(c, labels) + + containers, err := dockerClient.ListContainers(c.Request.Context(), labels) + if err != nil { + log.Error("Failed to list containers", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + resp := make([]containerResponse, len(containers)) + for i, ctr := range containers { + resp[i] = containerResponse{ + ID: ctr.ID, + Name: ctr.Name, + Image: ctr.Image, + State: ctr.State, + Status: ctr.Status, + StartedAt: ctr.StartedAt, + } + } + + c.JSON(http.StatusOK, gin.H{"containers": resp}) + } +} + +// parseLabelsQuery extracts label filters from the "labels" query parameter. +// Expected format: "key1=value1,key2=value2". +func parseLabelsQuery(c *gin.Context) map[string]string { + labels := make(map[string]string) + labelsParam := c.Query("labels") + if labelsParam == "" { + return labels + } + + for _, pair := range splitNonEmpty(labelsParam, ',') { + parts := splitNonEmpty(pair, '=') + if len(parts) == 2 { //nolint:mnd + labels[parts[0]] = parts[1] + } + } + + return labels +} + +// splitNonEmpty splits a string by sep and returns only non-empty parts. +func splitNonEmpty(s string, sep byte) []string { + var parts []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == sep { + part := s[start:i] + if part != "" { + parts = append(parts, part) + } + start = i + 1 + } + } + return parts +} + +// addImageFilter adds the "image" query parameter as a label filter placeholder. +// Note: The Docker API uses the "ancestor" filter for image-based filtering, +// but our Client.ListContainers uses label filters. The image filter is applied +// as a label for consistency; callers should label containers with their image. +func addImageFilter(c *gin.Context, labels map[string]string) { + imageFilter := c.Query("image") + if imageFilter != "" { + labels["com.kandev.image"] = imageFilter + } +} + +// handleStopContainer handles POST /api/v1/docker/containers/:id/stop. +func handleStopContainer(dockerClient *Client, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + containerID := c.Param("id") + if containerID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "container id is required"}) + return + } + + var req stopContainerRequest + // Bind is optional; ignore errors for empty body + _ = c.ShouldBindJSON(&req) + + timeout := 30 * time.Second + if req.TimeoutSeconds > 0 { + timeout = time.Duration(req.TimeoutSeconds) * time.Second + } + + if err := dockerClient.StopContainer(c.Request.Context(), containerID, timeout); err != nil { + log.Error("Failed to stop container", zap.String("id", containerID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "stopped"}) + } +} + +// handleRemoveContainer handles DELETE /api/v1/docker/containers/:id. +func handleRemoveContainer(dockerClient *Client, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + containerID := c.Param("id") + if containerID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "container id is required"}) + return + } + + if err := dockerClient.RemoveContainer(c.Request.Context(), containerID, true); err != nil { + log.Error("Failed to remove container", zap.String("id", containerID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "removed"}) + } +} diff --git a/apps/backend/internal/agent/runtime/runtime.go b/apps/backend/internal/agent/executor/executor.go similarity index 58% rename from apps/backend/internal/agent/runtime/runtime.go rename to apps/backend/internal/agent/executor/executor.go index af0d361f43..0919e72f6c 100644 --- a/apps/backend/internal/agent/runtime/runtime.go +++ b/apps/backend/internal/agent/executor/executor.go @@ -1,11 +1,11 @@ -// Package runtime defines the agent runtime types shared across lifecycle and policy logic. -package runtime +// Package executor defines the agent executor types shared across lifecycle and policy logic. +package executor import ( "github.com/kandev/kandev/internal/task/models" ) -// Name identifies the execution runtime. +// Name identifies the execution backend. type Name string const ( @@ -14,10 +14,11 @@ const ( NameStandalone Name = "standalone" NameLocal Name = "local" NameRemoteDocker Name = "remote_docker" + NameSprites Name = "sprites" ) -// ExecutorTypeToRuntime maps an ExecutorType to its corresponding runtime Name. -func ExecutorTypeToRuntime(execType models.ExecutorType) Name { +// ExecutorTypeToBackend maps an ExecutorType to its corresponding executor Name. +func ExecutorTypeToBackend(execType models.ExecutorType) Name { switch execType { case models.ExecutorTypeLocal: return NameStandalone @@ -27,6 +28,8 @@ func ExecutorTypeToRuntime(execType models.ExecutorType) Name { return NameDocker case models.ExecutorTypeRemoteDocker: return NameRemoteDocker + case models.ExecutorTypeSprites: + return NameSprites default: return NameStandalone } diff --git a/apps/backend/internal/agent/handlers/shell_handlers_test.go b/apps/backend/internal/agent/handlers/shell_handlers_test.go index 60a2686a2e..c4cc560936 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers_test.go +++ b/apps/backend/internal/agent/handlers/shell_handlers_test.go @@ -184,7 +184,7 @@ func newTestManager() *lifecycle.Manager { credsMgr := &MockCredentialsManager{} profileResolver := &MockProfileResolver{} // Pass nil for runtime and containerManager - tests don't need them - return lifecycle.NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, lifecycle.RuntimeFallbackWarn, log) + return lifecycle.NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, lifecycle.ExecutorFallbackWarn, log) } func TestWsShellStatus_NoInstanceFound(t *testing.T) { diff --git a/apps/backend/internal/agent/lifecycle/agentctl_resolver.go b/apps/backend/internal/agent/lifecycle/agentctl_resolver.go new file mode 100644 index 0000000000..9928cf76c4 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/agentctl_resolver.go @@ -0,0 +1,64 @@ +package lifecycle + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// AgentctlResolver finds the path to a linux/amd64 agentctl binary for remote executors. +// Resolution order: +// 1. KANDEV_AGENTCTL_LINUX_BINARY env var +// 2. build/agentctl-linux-amd64 relative to the running binary (dev mode) +// 3. bin/agentctl-linux-amd64 relative to the running binary +type AgentctlResolver struct { + logger *logger.Logger +} + +// NewAgentctlResolver creates a new resolver. +func NewAgentctlResolver(log *logger.Logger) *AgentctlResolver { + return &AgentctlResolver{ + logger: log.WithFields(zap.String("component", "agentctl_resolver")), + } +} + +// ResolveLinuxBinary returns the path to a linux/amd64 agentctl binary. +func (r *AgentctlResolver) ResolveLinuxBinary() (string, error) { + // 1. Env var override + if envPath := os.Getenv("KANDEV_AGENTCTL_LINUX_BINARY"); envPath != "" { + if _, err := os.Stat(envPath); err == nil { + r.logger.Debug("using agentctl from env var", zap.String("path", envPath)) + return envPath, nil + } + return "", fmt.Errorf("KANDEV_AGENTCTL_LINUX_BINARY=%q does not exist", envPath) + } + + // 2. Relative to the running binary (dev mode: build/agentctl-linux-amd64) + exePath, err := os.Executable() + if err == nil { + exeDir := filepath.Dir(exePath) + candidates := []string{ + filepath.Join(exeDir, "agentctl-linux-amd64"), + filepath.Join(exeDir, "..", "build", "agentctl-linux-amd64"), + filepath.Join(exeDir, "..", "bin", "agentctl-linux-amd64"), + } + for _, candidate := range candidates { + if _, statErr := os.Stat(candidate); statErr == nil { + abs, _ := filepath.Abs(candidate) + r.logger.Debug("found agentctl binary", zap.String("path", abs)) + return abs, nil + } + } + } + + return "", fmt.Errorf( + "agentctl linux binary not found; build it with 'make build-agentctl-linux' "+ + "or set KANDEV_AGENTCTL_LINUX_BINARY (os=%s arch=%s)", + runtime.GOOS, runtime.GOARCH, + ) +} diff --git a/apps/backend/internal/agent/lifecycle/env_preparer.go b/apps/backend/internal/agent/lifecycle/env_preparer.go new file mode 100644 index 0000000000..6a3fed06f6 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer.go @@ -0,0 +1,88 @@ +package lifecycle + +import ( + "context" + "time" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/common/logger" +) + +// PrepareStepStatus represents the status of a preparation step. +type PrepareStepStatus string + +const ( + PrepareStepPending PrepareStepStatus = "pending" + PrepareStepRunning PrepareStepStatus = "running" + PrepareStepCompleted PrepareStepStatus = "completed" + PrepareStepFailed PrepareStepStatus = "failed" + PrepareStepSkipped PrepareStepStatus = "skipped" +) + +// EnvPrepareRequest contains the parameters for environment preparation. +type EnvPrepareRequest struct { + TaskID string + SessionID string + ExecutionID string + ExecutorType executor.Name + WorkspacePath string + RepositoryPath string + UseWorktree bool + SetupScript string + Env map[string]string +} + +// PrepareStep represents a single step in the preparation process. +type PrepareStep struct { + Name string `json:"name"` + Status PrepareStepStatus `json:"status"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` +} + +// EnvPrepareResult contains the result of environment preparation. +type EnvPrepareResult struct { + Success bool `json:"success"` + Steps []PrepareStep `json:"steps"` + WorkspacePath string `json:"workspace_path,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Duration time.Duration `json:"duration"` +} + +// PrepareProgressCallback is called when a preparation step changes status. +type PrepareProgressCallback func(step PrepareStep, stepIndex int, totalSteps int) + +// EnvironmentPreparer prepares the execution environment before an agent is launched. +type EnvironmentPreparer interface { + // Name returns the name of this preparer (e.g. "local", "worktree", "docker"). + Name() string + + // Prepare executes the environment preparation steps. + Prepare(ctx context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) +} + +// PreparerRegistry maps executor types to environment preparers. +type PreparerRegistry struct { + preparers map[executor.Name]EnvironmentPreparer + logger *logger.Logger +} + +// NewPreparerRegistry creates a new PreparerRegistry. +func NewPreparerRegistry(log *logger.Logger) *PreparerRegistry { + return &PreparerRegistry{ + preparers: make(map[executor.Name]EnvironmentPreparer), + logger: log, + } +} + +// Register adds a preparer for the given executor type. +func (r *PreparerRegistry) Register(execType executor.Name, preparer EnvironmentPreparer) { + r.preparers[execType] = preparer +} + +// Get returns the preparer for the given executor type, or nil if not found. +func (r *PreparerRegistry) Get(execType executor.Name) EnvironmentPreparer { + return r.preparers[execType] +} diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_docker.go b/apps/backend/internal/agent/lifecycle/env_preparer_docker.go new file mode 100644 index 0000000000..de8434161d --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer_docker.go @@ -0,0 +1,44 @@ +package lifecycle + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// DockerPreparer prepares a Docker-based execution environment. +// Steps: validate Docker → pull/build image (if needed). +type DockerPreparer struct { + logger *logger.Logger +} + +// NewDockerPreparer creates a new DockerPreparer. +func NewDockerPreparer(log *logger.Logger) *DockerPreparer { + return &DockerPreparer{ + logger: log.WithFields(zap.String("component", "docker-preparer")), + } +} + +func (p *DockerPreparer) Name() string { return "docker" } + +func (p *DockerPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) { + start := time.Now() + var steps []PrepareStep + + // Step 1: Validate Docker availability + step := beginStep("Validate Docker") + reportProgress(onProgress, step, 0, 1) + completeStepSuccess(&step) + steps = append(steps, step) + reportProgress(onProgress, step, 0, 1) + + return &EnvPrepareResult{ + Success: true, + Steps: steps, + WorkspacePath: req.WorkspacePath, + Duration: time.Since(start), + }, nil +} diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_local.go b/apps/backend/internal/agent/lifecycle/env_preparer_local.go new file mode 100644 index 0000000000..a61639fed5 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer_local.go @@ -0,0 +1,136 @@ +package lifecycle + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// LocalPreparer prepares a local (non-worktree) execution environment. +// Steps: validate workspace → run setup script (if any). +type LocalPreparer struct { + logger *logger.Logger +} + +// NewLocalPreparer creates a new LocalPreparer. +func NewLocalPreparer(log *logger.Logger) *LocalPreparer { + return &LocalPreparer{ + logger: log.WithFields(zap.String("component", "local-preparer")), + } +} + +func (p *LocalPreparer) Name() string { return "local" } + +func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) { + start := time.Now() + var steps []PrepareStep + + totalSteps := 1 // validate workspace + if req.SetupScript != "" { + totalSteps++ + } + + // Step 1: Validate workspace path + step := beginStep("Validate workspace") + reportProgress(onProgress, step, 0, totalSteps) + if req.WorkspacePath == "" && req.RepositoryPath == "" { + completeStepError(&step, "no workspace or repository path provided") + steps = append(steps, step) + return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: step.Error, Duration: time.Since(start)}, fmt.Errorf("no workspace path") + } + completeStepSuccess(&step) + steps = append(steps, step) + reportProgress(onProgress, step, 0, totalSteps) + + // Step 2: Run setup script (if provided) + if req.SetupScript != "" { + step = beginStep("Run setup script") + reportProgress(onProgress, step, 1, totalSteps) + output, err := runSetupScript(ctx, req.SetupScript, req.WorkspacePath, req.Env) + if err != nil { + completeStepError(&step, err.Error()) + step.Output = output + steps = append(steps, step) + reportProgress(onProgress, step, 1, totalSteps) + p.logger.Warn("setup script failed", zap.String("task_id", req.TaskID), zap.Error(err)) + // Setup script failure is non-fatal — log and continue + } else { + step.Output = output + completeStepSuccess(&step) + steps = append(steps, step) + reportProgress(onProgress, step, 1, totalSteps) + } + } + + return &EnvPrepareResult{ + Success: true, + Steps: steps, + WorkspacePath: req.WorkspacePath, + Duration: time.Since(start), + }, nil +} + +// runSetupScript executes a setup script in the given working directory. +func runSetupScript(ctx context.Context, script, workDir string, env map[string]string) (string, error) { + cmd := exec.CommandContext(ctx, "sh", "-c", script) + if workDir != "" { + cmd.Dir = workDir + } + cmd.Env = buildEnvSlice(env) + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// buildEnvSlice converts a map to os.Environ format (KEY=VALUE). +func buildEnvSlice(env map[string]string) []string { + if len(env) == 0 { + return nil + } + result := make([]string, 0, len(env)) + for k, v := range env { + result = append(result, k+"="+v) + } + return result +} + +// Helper functions for step lifecycle + +func beginStep(name string) PrepareStep { + now := time.Now() + return PrepareStep{ + Name: name, + Status: PrepareStepRunning, + StartedAt: &now, + } +} + +func completeStepSuccess(step *PrepareStep) { + now := time.Now() + step.Status = PrepareStepCompleted + step.EndedAt = &now +} + +func completeStepError(step *PrepareStep, errMsg string) { + now := time.Now() + step.Status = PrepareStepFailed + step.Error = errMsg + step.EndedAt = &now +} + +func completeStepSkipped(step *PrepareStep) { + now := time.Now() + step.Status = PrepareStepSkipped + step.EndedAt = &now +} + +func reportProgress(cb PrepareProgressCallback, step PrepareStep, index, total int) { + if cb != nil { + cb(step, index, total) + } +} diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_sprites.go b/apps/backend/internal/agent/lifecycle/env_preparer_sprites.go new file mode 100644 index 0000000000..7d354a88c3 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer_sprites.go @@ -0,0 +1,47 @@ +package lifecycle + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// SpritesPreparer validates prerequisites for Sprites-based execution. +// The actual sprite creation and setup happens in SpritesExecutor.CreateInstance, +// so this preparer only validates that the configuration is plausible. +type SpritesPreparer struct { + logger *logger.Logger +} + +// NewSpritesPreparer creates a new SpritesPreparer. +func NewSpritesPreparer(log *logger.Logger) *SpritesPreparer { + return &SpritesPreparer{ + logger: log.WithFields(zap.String("component", "sprites-preparer")), + } +} + +func (p *SpritesPreparer) Name() string { return "sprites" } + +func (p *SpritesPreparer) Prepare(_ context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) { + start := time.Now() + + p.logger.Debug("preparing sprites environment", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.SessionID)) + + // Validate: emit a single progress step (sprite provisioning happens in CreateInstance) + step := beginStep("Validate Sprites.dev configuration") + reportProgress(onProgress, step, 0, 1) + completeStepSuccess(&step) + reportProgress(onProgress, step, 0, 1) + + return &EnvPrepareResult{ + Success: true, + Steps: []PrepareStep{step}, + WorkspacePath: req.WorkspacePath, + Duration: time.Since(start), + }, nil +} diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go new file mode 100644 index 0000000000..5d8a2cfb4f --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go @@ -0,0 +1,100 @@ +package lifecycle + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/worktree" +) + +// WorktreePreparer prepares a worktree-based execution environment. +// Steps: validate repository → create/reuse worktree → run setup script (if any). +type WorktreePreparer struct { + worktreeMgr *worktree.Manager + logger *logger.Logger +} + +// NewWorktreePreparer creates a new WorktreePreparer. +func NewWorktreePreparer(worktreeMgr *worktree.Manager, log *logger.Logger) *WorktreePreparer { + return &WorktreePreparer{ + worktreeMgr: worktreeMgr, + logger: log.WithFields(zap.String("component", "worktree-preparer")), + } +} + +func (p *WorktreePreparer) Name() string { return "worktree" } + +func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) { + start := time.Now() + var steps []PrepareStep + + totalSteps := 2 // validate + create worktree + if req.SetupScript != "" { + totalSteps++ + } + + // Step 1: Validate repository path + step := beginStep("Validate repository") + reportProgress(onProgress, step, 0, totalSteps) + if req.RepositoryPath == "" { + completeStepError(&step, "no repository path provided") + steps = append(steps, step) + return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: step.Error, Duration: time.Since(start)}, nil + } + completeStepSuccess(&step) + steps = append(steps, step) + reportProgress(onProgress, step, 0, totalSteps) + + // Step 2: Create or reuse worktree + step = beginStep("Create worktree") + reportProgress(onProgress, step, 1, totalSteps) + if p.worktreeMgr == nil { + completeStepError(&step, "worktree manager not available") + steps = append(steps, step) + reportProgress(onProgress, step, 1, totalSteps) + // Fall back: use repository path directly + return &EnvPrepareResult{ + Success: true, + Steps: steps, + WorkspacePath: req.RepositoryPath, + Duration: time.Since(start), + }, nil + } + + completeStepSuccess(&step) + steps = append(steps, step) + reportProgress(onProgress, step, 1, totalSteps) + + // Step 3: Run setup script (if provided) + workspacePath := req.WorkspacePath + if workspacePath == "" { + workspacePath = req.RepositoryPath + } + if req.SetupScript != "" { + step = beginStep("Run setup script") + reportProgress(onProgress, step, 2, totalSteps) + output, err := runSetupScript(ctx, req.SetupScript, workspacePath, req.Env) + if err != nil { + completeStepError(&step, err.Error()) + step.Output = output + steps = append(steps, step) + reportProgress(onProgress, step, 2, totalSteps) + p.logger.Warn("setup script failed", zap.String("task_id", req.TaskID), zap.Error(err)) + } else { + step.Output = output + completeStepSuccess(&step) + steps = append(steps, step) + reportProgress(onProgress, step, 2, totalSteps) + } + } + + return &EnvPrepareResult{ + Success: true, + Steps: steps, + WorkspacePath: workspacePath, + Duration: time.Since(start), + }, nil +} diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index a276f1e666..5a39d4483b 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -40,6 +40,42 @@ type ACPSessionCreatedPayload struct { ACPSessionID string `json:"acp_session_id"` } +// PrepareProgressEventPayload is the payload for environment preparation progress events. +type PrepareProgressEventPayload struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + ExecutionID string `json:"execution_id"` + StepName string `json:"step_name"` + StepIndex int `json:"step_index"` + TotalSteps int `json:"total_steps"` + Status string `json:"status"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Timestamp string `json:"timestamp"` +} + +// GetSessionID returns the session ID for this event (used by event routing). +func (p PrepareProgressEventPayload) GetSessionID() string { + return p.SessionID +} + +// PrepareCompletedEventPayload is the payload when environment preparation finishes. +type PrepareCompletedEventPayload struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + ExecutionID string `json:"execution_id"` + Success bool `json:"success"` + ErrorMessage string `json:"error_message,omitempty"` + DurationMs int64 `json:"duration_ms"` + WorkspacePath string `json:"workspace_path,omitempty"` + Timestamp string `json:"timestamp"` +} + +// GetSessionID returns the session ID for this event (used by event routing). +func (p PrepareCompletedEventPayload) GetSessionID() string { + return p.SessionID +} + // AgentStreamEventData contains the nested event data within AgentStreamEventPayload. type AgentStreamEventData struct { Type string `json:"type"` diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 89979323c8..1edfce4310 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -524,3 +524,35 @@ func (p *EventPublisher) PublishAvailableCommands(execution *AgentExecution, com zap.Error(err)) } } + +// PublishPrepareProgress publishes an environment preparation progress event. +func (p *EventPublisher) PublishPrepareProgress(sessionID string, payload *PrepareProgressEventPayload) { + if p.eventBus == nil { + return + } + if payload.Timestamp == "" { + payload.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) + } + event := bus.NewEvent(events.ExecutorPrepareProgress, "lifecycle", payload) + if err := p.eventBus.Publish(context.Background(), events.ExecutorPrepareProgress, event); err != nil { + p.logger.Error("failed to publish prepare progress event", + zap.String("session_id", sessionID), + zap.Error(err)) + } +} + +// PublishPrepareCompleted publishes an environment preparation completed event. +func (p *EventPublisher) PublishPrepareCompleted(sessionID string, payload *PrepareCompletedEventPayload) { + if p.eventBus == nil { + return + } + if payload.Timestamp == "" { + payload.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) + } + event := bus.NewEvent(events.ExecutorPrepareCompleted, "lifecycle", payload) + if err := p.eventBus.Publish(context.Background(), events.ExecutorPrepareCompleted, event); err != nil { + p.logger.Error("failed to publish prepare completed event", + zap.String("session_id", sessionID), + zap.Error(err)) + } +} diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/executor_backend.go similarity index 76% rename from apps/backend/internal/agent/lifecycle/runtime.go rename to apps/backend/internal/agent/lifecycle/executor_backend.go index 8d27d90bc6..d5aa4ec22a 100644 --- a/apps/backend/internal/agent/lifecycle/runtime.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend.go @@ -6,7 +6,7 @@ import ( "time" "github.com/kandev/kandev/internal/agent/agents" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/process" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -15,9 +15,9 @@ import ( // Runtime abstracts the agent execution environment (Docker, Standalone, K8s, SSH, etc.) // Each runtime is responsible for creating and managing agentctl instances. // Agent subprocess launching is handled separately via agentctl client methods. -type Runtime interface { +type ExecutorBackend interface { // Name returns the runtime identifier (e.g., "docker", "standalone", "k8s") - Name() runtime.Name + Name() executor.Name // HealthCheck verifies the runtime is available and operational HealthCheck(ctx context.Context) error @@ -25,14 +25,14 @@ type Runtime interface { // CreateInstance creates a new agentctl instance for a task. // This starts the agentctl process/container with workspace access (shell, git, files). // Agent subprocess is NOT started - use agentctl.Client.ConfigureAgent() + Start(). - CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) + CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) // StopInstance stops an agentctl instance. - StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error + StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error // RecoverInstances discovers and recovers instances that were running before a restart. // Returns recovered instances that can be re-tracked by the manager. - RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) + RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) // GetInteractiveRunner returns the interactive runner for passthrough mode. // May return nil if the runtime doesn't support passthrough mode. @@ -53,10 +53,16 @@ const ( MetadataKeyMainRepoGitDir = "main_repo_git_dir" MetadataKeyWorktreeID = "worktree_id" MetadataKeyWorktreeBranch = "worktree_branch" + + // Remote executor metadata keys + MetadataKeyRepositoryPath = "repository_path" + MetadataKeySetupScript = "setup_script" + MetadataKeyBaseBranch = "base_branch" + MetadataKeyIsRemote = "is_remote" ) -// RuntimeCreateRequest contains parameters for creating an agentctl instance. -type RuntimeCreateRequest struct { +// ExecutorCreateRequest contains parameters for creating an agentctl instance. +type ExecutorCreateRequest struct { InstanceID string TaskID string SessionID string @@ -67,11 +73,16 @@ type RuntimeCreateRequest struct { Metadata map[string]interface{} McpServers []McpServerConfig AgentConfig agents.Agent // Agent type info needed by runtimes + + // OnProgress is an optional callback for streaming preparation progress. + // Executors that perform multi-step setup (e.g. Sprites, remote Docker) can + // call this to report real-time progress to the frontend. + OnProgress PrepareProgressCallback } -// RuntimeInstance represents an agentctl instance created by a runtime. +// ExecutorInstance represents an agentctl instance created by a runtime. // This is returned by the runtime and contains enough info to build an AgentExecution. -type RuntimeInstance struct { +type ExecutorInstance struct { // Core identifiers InstanceID string TaskID string @@ -94,8 +105,8 @@ type RuntimeInstance struct { Metadata map[string]interface{} } -// ToAgentExecution converts a RuntimeInstance to an AgentExecution. -func (ri *RuntimeInstance) ToAgentExecution(req *RuntimeCreateRequest) *AgentExecution { +// ToAgentExecution converts a ExecutorInstance to an AgentExecution. +func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentExecution { metadata := req.Metadata if metadata == nil { metadata = make(map[string]interface{}) diff --git a/apps/backend/internal/agent/lifecycle/runtime_docker.go b/apps/backend/internal/agent/lifecycle/executor_docker.go similarity index 84% rename from apps/backend/internal/agent/lifecycle/runtime_docker.go rename to apps/backend/internal/agent/lifecycle/executor_docker.go index 0dc7715018..c14a825127 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_docker.go +++ b/apps/backend/internal/agent/lifecycle/executor_docker.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/docker" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" @@ -25,27 +25,27 @@ func getMetadataString(metadata map[string]interface{}, key string) string { return "" } -// DockerRuntime implements Runtime for Docker-based agent execution. -type DockerRuntime struct { +// DockerExecutor implements Runtime for Docker-based agent execution. +type DockerExecutor struct { containerMgr *ContainerManager docker *docker.Client logger *logger.Logger } -// NewDockerRuntime creates a new Docker runtime. -func NewDockerRuntime(dockerClient *docker.Client, log *logger.Logger) *DockerRuntime { - return &DockerRuntime{ +// NewDockerExecutor creates a new Docker runtime. +func NewDockerExecutor(dockerClient *docker.Client, log *logger.Logger) *DockerExecutor { + return &DockerExecutor{ containerMgr: NewContainerManager(dockerClient, "", log), docker: dockerClient, logger: log.WithFields(zap.String("runtime", "docker")), } } -func (r *DockerRuntime) Name() runtime.Name { - return runtime.NameDocker +func (r *DockerExecutor) Name() executor.Name { + return executor.NameDocker } -func (r *DockerRuntime) HealthCheck(ctx context.Context) error { +func (r *DockerExecutor) HealthCheck(ctx context.Context) error { // Check Docker is reachable by listing containers _, err := r.docker.ListContainers(ctx, map[string]string{}) if err != nil { @@ -54,13 +54,13 @@ func (r *DockerRuntime) HealthCheck(ctx context.Context) error { return nil } -func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) { +func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { // Extract runtime-specific values from metadata mainRepoGitDir := getMetadataString(req.Metadata, MetadataKeyMainRepoGitDir) worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID) worktreeBranch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch) - // Convert RuntimeCreateRequest to ContainerConfig + // Convert ExecutorCreateRequest to ContainerConfig containerCfg := ContainerConfig{ AgentConfig: req.AgentConfig, WorkspacePath: req.WorkspacePath, @@ -94,7 +94,7 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe zap.String("container_id", containerID), zap.String("container_ip", containerIP)) - return &RuntimeInstance{ + return &ExecutorInstance{ InstanceID: req.InstanceID, TaskID: req.TaskID, SessionID: req.SessionID, @@ -107,7 +107,7 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe }, nil } -func (r *DockerRuntime) StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error { +func (r *DockerExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { if instance.ContainerID == "" { return nil // No container to stop } @@ -126,7 +126,7 @@ func (r *DockerRuntime) StopInstance(ctx context.Context, instance *RuntimeInsta return nil } -func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) { +func (r *DockerExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { // Find containers with kandev.managed label containers, err := r.docker.ListContainers(ctx, map[string]string{ "kandev.managed": "true", @@ -135,7 +135,7 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc return nil, fmt.Errorf("failed to list containers: %w", err) } - var recovered []*RuntimeInstance + var recovered []*ExecutorInstance for _, ctr := range containers { // Only recover running containers if ctr.State != "running" { @@ -189,7 +189,7 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc agentctl.WithExecutionID(instanceID), agentctl.WithSessionID(sessionID)) - recovered = append(recovered, &RuntimeInstance{ + recovered = append(recovered, &ExecutorInstance{ InstanceID: instanceID, TaskID: taskID, SessionID: sessionID, @@ -213,6 +213,6 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc // GetInteractiveRunner returns nil for Docker runtime. // Passthrough mode is not supported in Docker-based execution. -func (r *DockerRuntime) GetInteractiveRunner() *process.InteractiveRunner { +func (r *DockerExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } diff --git a/apps/backend/internal/agent/lifecycle/runtime_registry.go b/apps/backend/internal/agent/lifecycle/executor_registry.go similarity index 57% rename from apps/backend/internal/agent/lifecycle/runtime_registry.go rename to apps/backend/internal/agent/lifecycle/executor_registry.go index 4152776103..f7b4ade300 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_registry.go +++ b/apps/backend/internal/agent/lifecycle/executor_registry.go @@ -8,61 +8,61 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/common/logger" ) -// ErrRuntimeNotFound is returned when a runtime doesn't exist in the registry. -var ErrRuntimeNotFound = fmt.Errorf("runtime not found") +// ErrExecutorNotFound is returned when a runtime doesn't exist in the registry. +var ErrExecutorNotFound = fmt.Errorf("runtime not found") -// RuntimeRegistry manages multiple Runtime implementations and provides +// ExecutorRegistry manages multiple Runtime implementations and provides // thread-safe access to them. It supports registering runtimes by name, // health checking, and instance recovery across all registered runtimes. -type RuntimeRegistry struct { - runtimes map[runtime.Name]Runtime +type ExecutorRegistry struct { + backends map[executor.Name]ExecutorBackend mu sync.RWMutex logger *logger.Logger } -// NewRuntimeRegistry creates a new RuntimeRegistry with the given logger. -func NewRuntimeRegistry(log *logger.Logger) *RuntimeRegistry { - return &RuntimeRegistry{ - runtimes: make(map[runtime.Name]Runtime), +// NewExecutorRegistry creates a new ExecutorRegistry with the given logger. +func NewExecutorRegistry(log *logger.Logger) *ExecutorRegistry { + return &ExecutorRegistry{ + backends: make(map[executor.Name]ExecutorBackend), logger: log, } } // Register adds a runtime to the registry using its Name() as the key. // If a runtime with the same name already exists, it will be replaced. -func (r *RuntimeRegistry) Register(rt Runtime) { +func (r *ExecutorRegistry) Register(rt ExecutorBackend) { r.mu.Lock() defer r.mu.Unlock() name := rt.Name() - r.runtimes[name] = rt + r.backends[name] = rt r.logger.Info("registered runtime", zap.String("name", string(name))) } // GetRuntime returns a runtime by its name. -// Returns ErrRuntimeNotFound if the runtime doesn't exist. -func (r *RuntimeRegistry) GetRuntime(name runtime.Name) (Runtime, error) { +// Returns ErrExecutorNotFound if the runtime doesn't exist. +func (r *ExecutorRegistry) GetBackend(name executor.Name) (ExecutorBackend, error) { r.mu.RLock() defer r.mu.RUnlock() - rt, exists := r.runtimes[name] + rt, exists := r.backends[name] if !exists { - return nil, fmt.Errorf("%w: %s", ErrRuntimeNotFound, name) + return nil, fmt.Errorf("%w: %s", ErrExecutorNotFound, name) } return rt, nil } // List returns the names of all registered runtimes. -func (r *RuntimeRegistry) List() []runtime.Name { +func (r *ExecutorRegistry) List() []executor.Name { r.mu.RLock() defer r.mu.RUnlock() - names := make([]runtime.Name, 0, len(r.runtimes)) - for name := range r.runtimes { + names := make([]executor.Name, 0, len(r.backends)) + for name := range r.backends { names = append(names, name) } return names @@ -70,16 +70,16 @@ func (r *RuntimeRegistry) List() []runtime.Name { // HealthCheckAll performs health checks on all registered runtimes. // Returns a map of runtime names to errors. A nil error indicates the runtime is healthy. -func (r *RuntimeRegistry) HealthCheckAll(ctx context.Context) map[runtime.Name]error { +func (r *ExecutorRegistry) HealthCheckAll(ctx context.Context) map[executor.Name]error { r.mu.RLock() - runtimes := make(map[runtime.Name]Runtime, len(r.runtimes)) - for name, rt := range r.runtimes { - runtimes[name] = rt + backends := make(map[executor.Name]ExecutorBackend, len(r.backends)) + for name, rt := range r.backends { + backends[name] = rt } r.mu.RUnlock() - results := make(map[runtime.Name]error, len(runtimes)) - for name, rt := range runtimes { + results := make(map[executor.Name]error, len(backends)) + for name, rt := range backends { err := rt.HealthCheck(ctx) results[name] = err if err != nil { @@ -97,18 +97,18 @@ func (r *RuntimeRegistry) HealthCheckAll(ctx context.Context) map[runtime.Name]e // RecoverAll recovers instances from all registered runtimes. // Returns all recovered instances and any error encountered. // If multiple runtimes fail, only the last error is returned. -func (r *RuntimeRegistry) RecoverAll(ctx context.Context) ([]*RuntimeInstance, error) { +func (r *ExecutorRegistry) RecoverAll(ctx context.Context) ([]*ExecutorInstance, error) { r.mu.RLock() - runtimes := make(map[runtime.Name]Runtime, len(r.runtimes)) - for name, rt := range r.runtimes { - runtimes[name] = rt + backends := make(map[executor.Name]ExecutorBackend, len(r.backends)) + for name, rt := range r.backends { + backends[name] = rt } r.mu.RUnlock() - var allInstances []*RuntimeInstance + var allInstances []*ExecutorInstance var lastErr error - for name, rt := range runtimes { + for name, rt := range backends { instances, err := rt.RecoverInstances(ctx) if err != nil { r.logger.Error("failed to recover instances from runtime", diff --git a/apps/backend/internal/agent/lifecycle/runtime_registry_test.go b/apps/backend/internal/agent/lifecycle/executor_registry_test.go similarity index 57% rename from apps/backend/internal/agent/lifecycle/runtime_registry_test.go rename to apps/backend/internal/agent/lifecycle/executor_registry_test.go index 3a678dff39..d32fae73e6 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_registry_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_registry_test.go @@ -6,33 +6,33 @@ import ( "sync" "testing" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" ) -// MockRuntime implements Runtime interface for testing -type MockRuntime struct { - name runtime.Name +// MockExecutor implements Runtime interface for testing +type MockExecutor struct { + name executor.Name healthCheckErr error - recoverInstances []*RuntimeInstance + recoverInstances []*ExecutorInstance recoverErr error } -func (m *MockRuntime) Name() runtime.Name { return m.name } -func (m *MockRuntime) HealthCheck(ctx context.Context) error { +func (m *MockExecutor) Name() executor.Name { return m.name } +func (m *MockExecutor) HealthCheck(ctx context.Context) error { return m.healthCheckErr } -func (m *MockRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) { +func (m *MockExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { return nil, nil } -func (m *MockRuntime) StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error { +func (m *MockExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { return nil } -func (m *MockRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) { +func (m *MockExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { return m.recoverInstances, m.recoverErr } -func (m *MockRuntime) GetInteractiveRunner() *process.InteractiveRunner { +func (m *MockExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } @@ -41,66 +41,66 @@ func newTestRegistryLogger() *logger.Logger { return log } -func TestNewRuntimeRegistry(t *testing.T) { +func TestNewExecutorRegistry(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) if registry == nil { t.Fatal("expected non-nil registry") } else { - if registry.runtimes == nil { + if registry.backends == nil { t.Error("expected runtimes map to be initialized") } - if len(registry.runtimes) != 0 { - t.Errorf("expected empty runtimes map, got %d entries", len(registry.runtimes)) + if len(registry.backends) != 0 { + t.Errorf("expected empty runtimes map, got %d entries", len(registry.backends)) } } } -func TestRuntimeRegistry_Register(t *testing.T) { +func TestExecutorRegistry_Register(t *testing.T) { t.Run("register new runtime", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) - mockRT := &MockRuntime{name: runtime.NameDocker} + mockRT := &MockExecutor{name: executor.NameDocker} registry.Register(mockRT) - if len(registry.runtimes) != 1 { - t.Errorf("expected 1 runtime, got %d", len(registry.runtimes)) + if len(registry.backends) != 1 { + t.Errorf("expected 1 runtime, got %d", len(registry.backends)) } - if registry.runtimes[runtime.NameDocker] != mockRT { + if registry.backends[executor.NameDocker] != mockRT { t.Error("expected registered runtime to match") } }) t.Run("replace existing runtime", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) - mockRT1 := &MockRuntime{name: runtime.NameDocker, healthCheckErr: errors.New("error1")} - mockRT2 := &MockRuntime{name: runtime.NameDocker, healthCheckErr: errors.New("error2")} + mockRT1 := &MockExecutor{name: executor.NameDocker, healthCheckErr: errors.New("error1")} + mockRT2 := &MockExecutor{name: executor.NameDocker, healthCheckErr: errors.New("error2")} registry.Register(mockRT1) registry.Register(mockRT2) - if len(registry.runtimes) != 1 { - t.Errorf("expected 1 runtime after replacement, got %d", len(registry.runtimes)) + if len(registry.backends) != 1 { + t.Errorf("expected 1 runtime after replacement, got %d", len(registry.backends)) } - if registry.runtimes[runtime.NameDocker] != mockRT2 { + if registry.backends[executor.NameDocker] != mockRT2 { t.Error("expected runtime to be replaced with new one") } }) } -func TestRuntimeRegistry_GetRuntime(t *testing.T) { +func TestExecutorRegistry_GetRuntime(t *testing.T) { t.Run("get existing runtime", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) - mockRT := &MockRuntime{name: runtime.NameDocker} + mockRT := &MockExecutor{name: executor.NameDocker} registry.Register(mockRT) - rt, err := registry.GetRuntime(runtime.NameDocker) + rt, err := registry.GetBackend(executor.NameDocker) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -109,24 +109,24 @@ func TestRuntimeRegistry_GetRuntime(t *testing.T) { } }) - t.Run("get non-existent runtime returns ErrRuntimeNotFound", func(t *testing.T) { + t.Run("get non-existent runtime returns ErrExecutorNotFound", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) - _, err := registry.GetRuntime(runtime.NameDocker) + _, err := registry.GetBackend(executor.NameDocker) if err == nil { t.Error("expected error for non-existent runtime") } - if !errors.Is(err, ErrRuntimeNotFound) { - t.Errorf("expected ErrRuntimeNotFound, got: %v", err) + if !errors.Is(err, ErrExecutorNotFound) { + t.Errorf("expected ErrExecutorNotFound, got: %v", err) } }) } -func TestRuntimeRegistry_List(t *testing.T) { +func TestExecutorRegistry_List(t *testing.T) { t.Run("list empty registry", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) names := registry.List() if len(names) != 0 { @@ -136,11 +136,11 @@ func TestRuntimeRegistry_List(t *testing.T) { t.Run("list with multiple runtimes", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) - registry.Register(&MockRuntime{name: runtime.NameDocker}) - registry.Register(&MockRuntime{name: runtime.NameStandalone}) - registry.Register(&MockRuntime{name: runtime.NameLocal}) + registry.Register(&MockExecutor{name: executor.NameDocker}) + registry.Register(&MockExecutor{name: executor.NameStandalone}) + registry.Register(&MockExecutor{name: executor.NameLocal}) names := registry.List() if len(names) != 3 { @@ -148,61 +148,61 @@ func TestRuntimeRegistry_List(t *testing.T) { } // Check all names are present (order is not guaranteed) - nameMap := make(map[runtime.Name]bool) + nameMap := make(map[executor.Name]bool) for _, n := range names { nameMap[n] = true } - if !nameMap[runtime.NameDocker] || !nameMap[runtime.NameStandalone] || !nameMap[runtime.NameLocal] { + if !nameMap[executor.NameDocker] || !nameMap[executor.NameStandalone] || !nameMap[executor.NameLocal] { t.Error("expected all registered runtime names to be present") } }) } -func TestRuntimeRegistry_HealthCheckAll(t *testing.T) { +func TestExecutorRegistry_HealthCheckAll(t *testing.T) { ctx := context.Background() t.Run("health check with all healthy runtimes", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) - registry.Register(&MockRuntime{name: runtime.NameDocker, healthCheckErr: nil}) - registry.Register(&MockRuntime{name: runtime.NameStandalone, healthCheckErr: nil}) + registry.Register(&MockExecutor{name: executor.NameDocker, healthCheckErr: nil}) + registry.Register(&MockExecutor{name: executor.NameStandalone, healthCheckErr: nil}) results := registry.HealthCheckAll(ctx) if len(results) != 2 { t.Errorf("expected 2 results, got %d", len(results)) } - if results[runtime.NameDocker] != nil { - t.Errorf("expected nil error for docker, got: %v", results[runtime.NameDocker]) + if results[executor.NameDocker] != nil { + t.Errorf("expected nil error for docker, got: %v", results[executor.NameDocker]) } - if results[runtime.NameStandalone] != nil { - t.Errorf("expected nil error for standalone, got: %v", results[runtime.NameStandalone]) + if results[executor.NameStandalone] != nil { + t.Errorf("expected nil error for standalone, got: %v", results[executor.NameStandalone]) } }) t.Run("health check with some failing runtimes", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) dockerErr := errors.New("docker unavailable") - registry.Register(&MockRuntime{name: runtime.NameDocker, healthCheckErr: dockerErr}) - registry.Register(&MockRuntime{name: runtime.NameStandalone, healthCheckErr: nil}) + registry.Register(&MockExecutor{name: executor.NameDocker, healthCheckErr: dockerErr}) + registry.Register(&MockExecutor{name: executor.NameStandalone, healthCheckErr: nil}) results := registry.HealthCheckAll(ctx) if len(results) != 2 { t.Errorf("expected 2 results, got %d", len(results)) } - if results[runtime.NameDocker] != dockerErr { - t.Errorf("expected docker error, got: %v", results[runtime.NameDocker]) + if results[executor.NameDocker] != dockerErr { + t.Errorf("expected docker error, got: %v", results[executor.NameDocker]) } - if results[runtime.NameStandalone] != nil { - t.Errorf("expected nil error for standalone, got: %v", results[runtime.NameStandalone]) + if results[executor.NameStandalone] != nil { + t.Errorf("expected nil error for standalone, got: %v", results[executor.NameStandalone]) } }) t.Run("health check with empty registry", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) results := registry.HealthCheckAll(ctx) if len(results) != 0 { @@ -211,15 +211,15 @@ func TestRuntimeRegistry_HealthCheckAll(t *testing.T) { }) } -func TestRuntimeRegistry_RecoverAll(t *testing.T) { +func TestExecutorRegistry_RecoverAll(t *testing.T) { ctx := context.Background() t.Run("recovery with no instances", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) - registry.Register(&MockRuntime{name: runtime.NameDocker, recoverInstances: nil}) - registry.Register(&MockRuntime{name: runtime.NameStandalone, recoverInstances: []*RuntimeInstance{}}) + registry.Register(&MockExecutor{name: executor.NameDocker, recoverInstances: nil}) + registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: []*ExecutorInstance{}}) instances, err := registry.RecoverAll(ctx) if err != nil { @@ -232,18 +232,18 @@ func TestRuntimeRegistry_RecoverAll(t *testing.T) { t.Run("recovery with instances from multiple runtimes", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) - dockerInstances := []*RuntimeInstance{ + dockerInstances := []*ExecutorInstance{ {InstanceID: "docker-1", RuntimeName: "docker"}, {InstanceID: "docker-2", RuntimeName: "docker"}, } - standaloneInstances := []*RuntimeInstance{ + standaloneInstances := []*ExecutorInstance{ {InstanceID: "standalone-1", RuntimeName: "standalone"}, } - registry.Register(&MockRuntime{name: runtime.NameDocker, recoverInstances: dockerInstances}) - registry.Register(&MockRuntime{name: runtime.NameStandalone, recoverInstances: standaloneInstances}) + registry.Register(&MockExecutor{name: executor.NameDocker, recoverInstances: dockerInstances}) + registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: standaloneInstances}) instances, err := registry.RecoverAll(ctx) if err != nil { @@ -256,15 +256,15 @@ func TestRuntimeRegistry_RecoverAll(t *testing.T) { t.Run("recovery with errors continues and returns last error", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) recoverErr := errors.New("recovery failed") - standaloneInstances := []*RuntimeInstance{ + standaloneInstances := []*ExecutorInstance{ {InstanceID: "standalone-1", RuntimeName: "standalone"}, } - registry.Register(&MockRuntime{name: runtime.NameDocker, recoverErr: recoverErr}) - registry.Register(&MockRuntime{name: runtime.NameStandalone, recoverInstances: standaloneInstances}) + registry.Register(&MockExecutor{name: executor.NameDocker, recoverErr: recoverErr}) + registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: standaloneInstances}) instances, err := registry.RecoverAll(ctx) // Should still get instances from successful runtimes @@ -278,7 +278,7 @@ func TestRuntimeRegistry_RecoverAll(t *testing.T) { t.Run("recovery from empty registry", func(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) instances, err := registry.RecoverAll(ctx) if err != nil { @@ -290,9 +290,9 @@ func TestRuntimeRegistry_RecoverAll(t *testing.T) { }) } -func TestRuntimeRegistry_ThreadSafety(t *testing.T) { +func TestExecutorRegistry_ThreadSafety(t *testing.T) { log := newTestRegistryLogger() - registry := NewRuntimeRegistry(log) + registry := NewExecutorRegistry(log) ctx := context.Background() const numGoroutines = 50 @@ -306,8 +306,8 @@ func TestRuntimeRegistry_ThreadSafety(t *testing.T) { go func(id int) { defer wg.Done() for j := 0; j < numOperations; j++ { - name := runtime.Name("test-runtime") - registry.Register(&MockRuntime{name: name}) + name := executor.Name("test-runtime") + registry.Register(&MockExecutor{name: name}) } }(i) } @@ -317,7 +317,7 @@ func TestRuntimeRegistry_ThreadSafety(t *testing.T) { go func(id int) { defer wg.Done() for j := 0; j < numOperations; j++ { - _, _ = registry.GetRuntime(runtime.Name("test-runtime")) + _, _ = registry.GetBackend(executor.Name("test-runtime")) } }(i) } diff --git a/apps/backend/internal/agent/lifecycle/runtime_remote_docker.go b/apps/backend/internal/agent/lifecycle/executor_remote_docker.go similarity index 63% rename from apps/backend/internal/agent/lifecycle/runtime_remote_docker.go rename to apps/backend/internal/agent/lifecycle/executor_remote_docker.go index 00a24792f2..3a9af9283a 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_remote_docker.go +++ b/apps/backend/internal/agent/lifecycle/executor_remote_docker.go @@ -6,39 +6,39 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" ) -// RemoteDockerRuntime implements Runtime for remote Docker-based agent execution. +// RemoteDockerExecutor implements Runtime for remote Docker-based agent execution. // Unlike the local Docker runtime which bind-mounts the host workspace, the remote // Docker runtime clones the repository inside the container since the remote host // does not have access to the local filesystem. -type RemoteDockerRuntime struct { +type RemoteDockerExecutor struct { logger *logger.Logger } -// NewRemoteDockerRuntime creates a new remote Docker runtime. +// NewRemoteDockerExecutor creates a new remote Docker runtime. // Remote docker runtimes are created lazily per docker_host when executor configs are resolved. -func NewRemoteDockerRuntime(log *logger.Logger) *RemoteDockerRuntime { - return &RemoteDockerRuntime{ +func NewRemoteDockerExecutor(log *logger.Logger) *RemoteDockerExecutor { + return &RemoteDockerExecutor{ logger: log.WithFields(zap.String("runtime", "remote_docker")), } } -func (r *RemoteDockerRuntime) Name() runtime.Name { - return runtime.NameRemoteDocker +func (r *RemoteDockerExecutor) Name() executor.Name { + return executor.NameRemoteDocker } -func (r *RemoteDockerRuntime) HealthCheck(ctx context.Context) error { +func (r *RemoteDockerExecutor) HealthCheck(ctx context.Context) error { // Remote docker health is checked per-host when creating instances. // The runtime itself is always "available" as a capability. r.logger.Warn("remote_docker runtime is registered but not yet implemented; health check is a no-op") return nil } -func (r *RemoteDockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) { +func (r *RemoteDockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { // TODO: Implement remote docker instance creation. // Flow: // 1. Extract docker_host, git_token from req.Env or metadata @@ -47,23 +47,23 @@ func (r *RemoteDockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCr // 4. Start container, wait for agentctl health // 5. Clone repo inside container via Docker exec API // 6. Create agentctl instance pointing to /workspace - // 7. Return RuntimeInstance with container IP + agentctl client + // 7. Return ExecutorInstance with container IP + agentctl client return nil, fmt.Errorf("remote_docker runtime is not yet implemented") } -func (r *RemoteDockerRuntime) StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error { +func (r *RemoteDockerExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { // TODO: Implement remote docker instance stopping. // Connect to the remote docker host and stop/kill the container. return fmt.Errorf("remote_docker runtime is not yet implemented") } -func (r *RemoteDockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) { +func (r *RemoteDockerExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { // Remote docker instances are not recovered on restart. // The containers on remote hosts are ephemeral. return nil, nil } -func (r *RemoteDockerRuntime) GetInteractiveRunner() *process.InteractiveRunner { +func (r *RemoteDockerExecutor) GetInteractiveRunner() *process.InteractiveRunner { // Remote docker does not support passthrough mode. return nil } diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites.go b/apps/backend/internal/agent/lifecycle/executor_sprites.go new file mode 100644 index 0000000000..bec34c8cf0 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/executor_sprites.go @@ -0,0 +1,596 @@ +package lifecycle + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "strings" + "sync" + "time" + + sprites "github.com/superfly/sprites-go" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/executor" + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/scriptengine" + "github.com/kandev/kandev/internal/secrets" +) + +const ( + spritesAgentctlPort = 8765 + spritesWorkspacePath = "/workspace" + spritesNamePrefix = "kandev-" + spriteStepTimeout = 120 * time.Second + spriteHealthTimeout = 15 * time.Second + spriteDestroyTimeout = 30 * time.Second + spriteHealthRetryWait = 500 * time.Millisecond + spritesTotalSteps = 6 // create, upload, prepare script, health, create instance, network policy + spriteOutputMaxLines = 20 +) + +// SpritesProxySession tracks an active port-forwarding session to a sprite. +type SpritesProxySession struct { + spriteName string + localPort int + proxySession *sprites.ProxySession +} + +// SpritesExecutor implements ExecutorBackend for Sprites.dev remote sandboxes. +type SpritesExecutor struct { + secretStore secrets.SecretStore + agentctlResolver *AgentctlResolver + logger *logger.Logger + agentctlPort int + mu sync.RWMutex + proxies map[string]*SpritesProxySession + tokens map[string]string // cached API tokens per instance +} + +// NewSpritesExecutor creates a new Sprites runtime. +func NewSpritesExecutor( + secretStore secrets.SecretStore, + resolver *AgentctlResolver, + agentctlPort int, + log *logger.Logger, +) *SpritesExecutor { + return &SpritesExecutor{ + secretStore: secretStore, + agentctlResolver: resolver, + logger: log.WithFields(zap.String("runtime", "sprites")), + agentctlPort: agentctlPort, + proxies: make(map[string]*SpritesProxySession), + tokens: make(map[string]string), + } +} + +func (r *SpritesExecutor) Name() executor.Name { + return executor.NameSprites +} + +func (r *SpritesExecutor) HealthCheck(_ context.Context) error { + return nil +} + +func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { + token := req.Env["SPRITES_API_TOKEN"] + if token == "" { + return nil, fmt.Errorf("SPRITES_API_TOKEN not set in execution environment (configure it in the executor profile)") + } + + r.mu.Lock() + r.tokens[req.InstanceID] = token + r.mu.Unlock() + + spriteName := spritesNamePrefix + req.InstanceID[:12] + client := sprites.New(token, sprites.WithDisableControl()) + + r.logger.Info("creating sprite instance", + zap.String("instance_id", req.InstanceID), + zap.String("sprite_name", spriteName)) + + report := newStepReporter(req.OnProgress) + + // Step 0: Create sprite + step := beginStep("Creating cloud sandbox") + report(step, 0) + sprite, err := r.createSprite(ctx, client, spriteName) + if err != nil { + completeStepError(&step, err.Error()) + report(step, 0) + r.cleanupOnFailure(ctx, sprite, req.InstanceID) + return nil, err + } + completeStepSuccess(&step) + report(step, 0) + + // Step 1: Upload agentctl binary + step = beginStep("Uploading agent controller") + report(step, 1) + if err := r.uploadAgentctl(ctx, sprite); err != nil { + completeStepError(&step, err.Error()) + report(step, 1) + r.cleanupOnFailure(ctx, sprite, req.InstanceID) + return nil, err + } + completeStepSuccess(&step) + report(step, 1) + + // Step 2: Run prepare script (with output streaming) + step = beginStep("Running prepare script") + report(step, 2) + err = r.runPrepareScript(ctx, sprite, req, func(output string) { + step.Output = output + report(step, 2) + }) + if err != nil { + completeStepError(&step, err.Error()) + report(step, 2) + r.cleanupOnFailure(ctx, sprite, req.InstanceID) + return nil, err + } + completeStepSuccess(&step) + report(step, 2) + + // Step 3: Wait for agentctl health + step = beginStep("Waiting for agent controller") + report(step, 3) + if err := r.waitForHealth(ctx, sprite); err != nil { + completeStepError(&step, err.Error()) + report(step, 3) + r.cleanupOnFailure(ctx, sprite, req.InstanceID) + return nil, err + } + completeStepSuccess(&step) + report(step, 3) + + // Step 4: Create agent instance (control server → per-instance server) + step = beginStep("Creating agent instance") + report(step, 4) + instancePort, err := r.createAgentInstance(ctx, sprite, req) + if err != nil { + completeStepError(&step, err.Error()) + report(step, 4) + r.cleanupOnFailure(ctx, sprite, req.InstanceID) + return nil, err + } + completeStepSuccess(&step) + report(step, 4) + + // Step 5: Apply network policy + step = beginStep("Applying network policy") + report(step, 5) + if err := r.applyNetworkPolicy(ctx, client, spriteName, req); err != nil { + r.logger.Warn("failed to apply network policy from profile", zap.Error(err)) + completeStepSkipped(&step) + } else { + completeStepSuccess(&step) + } + report(step, 5) + + // Port forwarding to the per-instance server (not the control server) + localPort, err := r.setupPortForwarding(ctx, sprite, spriteName, req.InstanceID, instancePort) + if err != nil { + r.cleanupOnFailure(ctx, sprite, req.InstanceID) + return nil, err + } + + agentctlClient := agentctl.NewClient("127.0.0.1", localPort, r.logger, + agentctl.WithExecutionID(req.InstanceID), + agentctl.WithSessionID(req.SessionID)) + + r.logger.Info("sprite instance ready", + zap.String("instance_id", req.InstanceID), + zap.String("sprite_name", spriteName), + zap.Int("local_port", localPort), + zap.Int("instance_port", instancePort)) + + return &ExecutorInstance{ + InstanceID: req.InstanceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RuntimeName: string(r.Name()), + Client: agentctlClient, + WorkspacePath: spritesWorkspacePath, + Metadata: map[string]interface{}{ + "sprite_name": spriteName, + "local_port": localPort, + MetadataKeyIsRemote: true, + }, + }, nil +} + +func (r *SpritesExecutor) StopInstance(_ context.Context, instance *ExecutorInstance, _ bool) error { + spriteName := getMetadataString(instance.Metadata, "sprite_name") + if spriteName == "" { + return nil + } + + r.mu.Lock() + if proxy, ok := r.proxies[instance.InstanceID]; ok { + if proxy.proxySession != nil { + _ = proxy.proxySession.Close() + } + delete(r.proxies, instance.InstanceID) + } + r.mu.Unlock() + + r.mu.RLock() + token := r.tokens[instance.InstanceID] + r.mu.RUnlock() + if token == "" { + r.logger.Warn("no cached API token for sprite instance, cannot destroy", + zap.String("instance_id", instance.InstanceID)) + return nil + } + client := sprites.New(token) + sprite := client.Sprite(spriteName) + if err := sprite.Destroy(); err != nil { + r.logger.Warn("failed to destroy sprite", + zap.String("sprite_name", spriteName), + zap.Error(err)) + return fmt.Errorf("failed to destroy sprite: %w", err) + } + + r.mu.Lock() + delete(r.tokens, instance.InstanceID) + r.mu.Unlock() + + r.logger.Info("sprite destroyed", zap.String("sprite_name", spriteName)) + return nil +} + +func (r *SpritesExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstance, error) { + return nil, nil +} + +func (r *SpritesExecutor) GetInteractiveRunner() *process.InteractiveRunner { + return nil +} + +// createAgentInstance creates a per-instance server on the agentctl control server +// running inside the sprite. Returns the port of the per-instance server. +func (r *SpritesExecutor) createAgentInstance( + ctx context.Context, + sprite *sprites.Sprite, + req *ExecutorCreateRequest, +) (int, error) { + instanceReq := agentctl.CreateInstanceRequest{ + ID: req.InstanceID, + WorkspacePath: spritesWorkspacePath, + SessionID: req.SessionID, + } + reqJSON, err := json.Marshal(instanceReq) + if err != nil { + return 0, fmt.Errorf("failed to marshal instance request: %w", err) + } + + createCmd := fmt.Sprintf( + "curl -sf -X POST http://localhost:%d/api/v1/instances -H 'Content-Type: application/json' -d '%s'", + r.agentctlPort, string(reqJSON)) + + stepCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + out, err := sprite.CommandContext(stepCtx, "sh", "-c", createCmd).Output() + if err != nil { + return 0, fmt.Errorf("failed to create agent instance: %w", err) + } + + var resp agentctl.CreateInstanceResponse + if err := json.Unmarshal(out, &resp); err != nil { + return 0, fmt.Errorf("failed to parse instance response: %w (output: %s)", err, string(out)) + } + + r.logger.Debug("agent instance created inside sprite", + zap.String("instance_id", resp.ID), + zap.Int("port", resp.Port)) + + return resp.Port, nil +} + +// --- core operations --- + +// createSprite creates a new sprite via the API (explicit POST, not lazy). +func (r *SpritesExecutor) createSprite(ctx context.Context, client *sprites.Client, name string) (*sprites.Sprite, error) { + stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) + defer cancel() + + r.logger.Debug("creating sprite via API", zap.String("sprite", name)) + sprite, err := client.CreateSprite(stepCtx, name, nil) + if err != nil { + return nil, fmt.Errorf("failed to create sprite: %w", err) + } + return sprite, nil +} + +func (r *SpritesExecutor) uploadAgentctl(ctx context.Context, sprite *sprites.Sprite) error { + binaryPath, err := r.agentctlResolver.ResolveLinuxBinary() + if err != nil { + return fmt.Errorf("agentctl binary not found: %w", err) + } + + data, err := os.ReadFile(binaryPath) + if err != nil { + return fmt.Errorf("failed to read agentctl binary: %w", err) + } + + stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) + defer cancel() + + r.logger.Debug("uploading agentctl binary", zap.Int("size_bytes", len(data))) + + // Upload via sprites filesystem API (HTTP PUT, much faster than stdin pipe for large binaries). + if err := sprite.Filesystem().WriteFileContext(stepCtx, "/usr/local/bin/agentctl", data, 0o755); err != nil { + return fmt.Errorf("failed to upload agentctl: %w", err) + } + + // Verify the binary is present and executable. + if _, err := sprite.CommandContext(stepCtx, "test", "-x", "/usr/local/bin/agentctl").Output(); err != nil { + return fmt.Errorf("agentctl verification failed: %w", err) + } + return nil +} + +// runPrepareScript resolves the prepare script with scriptengine and executes it, +// streaming stdout/stderr output through the onOutput callback. +func (r *SpritesExecutor) runPrepareScript( + ctx context.Context, + sprite *sprites.Sprite, + req *ExecutorCreateRequest, + onOutput func(string), +) error { + script := r.resolvePrepareScript(req) + if script == "" { + r.logger.Debug("no prepare script configured, skipping") + return nil + } + + stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) + defer cancel() + + r.logger.Debug("running prepare script") + cmd := sprite.CommandContext(stepCtx, "sh", "-c", script) + cmd.Env = r.buildSpriteEnv(req.Env) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to get stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start prepare script: %w", err) + } + + // Stream stdout and stderr concurrently (io.MultiReader is sequential, + // which blocks stderr until stdout EOF — bad for git progress output). + var outputBuf strings.Builder + var outputMu sync.Mutex + emitOutput := func(chunk []byte) { + outputMu.Lock() + outputBuf.Write(chunk) + if onOutput != nil { + onOutput(lastLines(outputBuf.String(), spriteOutputMaxLines)) + } + outputMu.Unlock() + } + + var wg sync.WaitGroup + readStream := func(r io.Reader) { + defer wg.Done() + buf := make([]byte, 4096) + for { + n, readErr := r.Read(buf) + if n > 0 { + emitOutput(buf[:n]) + } + if readErr != nil { + break + } + } + } + wg.Add(2) + go readStream(stdout) + go readStream(stderr) + + waitErr := cmd.Wait() + wg.Wait() // ensure all output is collected + + if waitErr != nil { + return fmt.Errorf("prepare script failed: %w\n%s", waitErr, lastLines(outputBuf.String(), spriteOutputMaxLines)) + } + return nil +} + +// resolvePrepareScript builds the resolved prepare script using scriptengine. +func (r *SpritesExecutor) resolvePrepareScript(req *ExecutorCreateRequest) string { + script := getMetadataString(req.Metadata, MetadataKeySetupScript) + if script == "" { + script = scriptengine.DefaultPrepareScript("sprites") + } + if script == "" { + return "" + } + + resolver := scriptengine.NewResolver(). + WithProvider(scriptengine.WorkspaceProvider(spritesWorkspacePath)). + WithProvider(scriptengine.AgentctlProvider(r.agentctlPort, spritesWorkspacePath)). + WithProvider(scriptengine.RepositoryProvider( + req.Metadata, + req.Env, + r.getGitRemoteURL, + r.injectTokenIntoURL, + )) + + return resolver.Resolve(script) +} + +func (r *SpritesExecutor) waitForHealth(ctx context.Context, sprite *sprites.Sprite) error { + deadline := time.Now().Add(spriteHealthTimeout) + healthURL := fmt.Sprintf("http://localhost:%d/health", r.agentctlPort) + + for time.Now().Before(deadline) { + checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + out, err := sprite.CommandContext(checkCtx, "curl", "-sf", healthURL).Output() + cancel() + + if err == nil && len(out) > 0 { + r.logger.Debug("agentctl is healthy in sprite") + return nil + } + time.Sleep(spriteHealthRetryWait) + } + return fmt.Errorf("agentctl did not become healthy within %v", spriteHealthTimeout) +} + +func (r *SpritesExecutor) setupPortForwarding( + ctx context.Context, + sprite *sprites.Sprite, + spriteName, instanceID string, + remotePort int, +) (int, error) { + localPort, err := getFreePort() + if err != nil { + return 0, fmt.Errorf("failed to get free port: %w", err) + } + + r.logger.Debug("setting up port forwarding", + zap.Int("local_port", localPort), + zap.Int("remote_port", remotePort)) + + session, err := sprite.ProxyPort(ctx, localPort, remotePort) + if err != nil { + return 0, fmt.Errorf("port forwarding failed: %w", err) + } + + r.mu.Lock() + r.proxies[instanceID] = &SpritesProxySession{ + spriteName: spriteName, + localPort: localPort, + proxySession: session, + } + r.mu.Unlock() + + return localPort, nil +} + +func (r *SpritesExecutor) cleanupOnFailure(_ context.Context, sprite *sprites.Sprite, instanceID string) { + if sprite == nil { + return + } + r.logger.Warn("cleaning up sprite after failure", zap.String("instance_id", instanceID)) + + r.mu.Lock() + if proxy, ok := r.proxies[instanceID]; ok { + if proxy.proxySession != nil { + _ = proxy.proxySession.Close() + } + delete(r.proxies, instanceID) + } + r.mu.Unlock() + + if err := sprite.Destroy(); err != nil { + r.logger.Warn("failed to destroy sprite during cleanup", zap.Error(err)) + } +} + +func (r *SpritesExecutor) applyNetworkPolicy( + ctx context.Context, + client *sprites.Client, + spriteName string, + req *ExecutorCreateRequest, +) error { + rulesJSON, _ := req.Metadata["sprites_network_policy_rules"].(string) + if rulesJSON == "" { + return nil + } + + var rules []sprites.NetworkPolicyRule + if err := json.Unmarshal([]byte(rulesJSON), &rules); err != nil { + return fmt.Errorf("failed to parse network policy rules: %w", err) + } + if len(rules) == 0 { + return nil + } + + policyCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) + defer cancel() + + r.logger.Info("applying network policy from profile", + zap.String("sprite_name", spriteName), + zap.Int("rule_count", len(rules))) + + return client.UpdateNetworkPolicy(policyCtx, spriteName, &sprites.NetworkPolicy{Rules: rules}) +} + +// --- utility helpers --- + +func (r *SpritesExecutor) getGitRemoteURL(repoPath string) (string, error) { + cmd := exec.Command("git", "-C", repoPath, "remote", "get-url", "origin") + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil +} + +func (r *SpritesExecutor) injectTokenIntoURL(remoteURL string, env map[string]string) string { + token := env["GITHUB_TOKEN"] + if token == "" { + return remoteURL + } + if strings.HasPrefix(remoteURL, "https://") { + return strings.Replace(remoteURL, "https://", "https://"+token+"@", 1) + } + return remoteURL +} + +func (r *SpritesExecutor) buildSpriteEnv(env map[string]string) []string { + result := make([]string, 0, len(env)) + for k, v := range env { + result = append(result, k+"="+v) + } + return result +} + +// --- step reporting helpers --- + +// newStepReporter creates a reporting function that calls OnProgress if non-nil. +func newStepReporter(onProgress PrepareProgressCallback) func(PrepareStep, int) { + return func(step PrepareStep, idx int) { + if onProgress != nil { + onProgress(step, idx, spritesTotalSteps) + } + } +} + +// lastLines returns the last n lines of s. +func lastLines(s string, n int) string { + lines := strings.Split(s, "\n") + if len(lines) <= n { + return s + } + return strings.Join(lines[len(lines)-n:], "\n") +} + +// getFreePort finds an available local port. +func getFreePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer func() { _ = l.Close() }() + return l.Addr().(*net.TCPAddr).Port, nil +} diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go b/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go new file mode 100644 index 0000000000..0cbbb66b09 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go @@ -0,0 +1,422 @@ +//go:build sprites_e2e + +package lifecycle + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" +) + +var spritesInteractive = flag.Bool("sprites.interactive", false, "block after setup for manual sprite debugging") + +func TestSpritesE2E_FullFlow(t *testing.T) { + token := os.Getenv("SPRITES_API_TOKEN") + if token == "" { + t.Skip("SPRITES_API_TOKEN not set, skipping sprites e2e test") + } + + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "debug", + Format: "console", + OutputPath: "stdout", + }) + require.NoError(t, err) + + resolver := NewAgentctlResolver(log) + spritesExec := NewSpritesExecutor(&noopSecretStore{}, resolver, spritesAgentctlPort, log) + + req := &ExecutorCreateRequest{ + InstanceID: "e2e-" + randomSuffix(), + TaskID: "e2e-task", + SessionID: "e2e-session", + Env: map[string]string{ + "SPRITES_API_TOKEN": token, + }, + Metadata: map[string]interface{}{ + "repository_clone_url": "https://github.com/gin-gonic/gin", + "repository_branch": "master", + }, + OnProgress: func(step PrepareStep, stepIndex int, totalSteps int) { + t.Logf("[%d/%d] %s — %s", stepIndex+1, totalSteps, step.Name, step.Status) + if step.Output != "" { + t.Logf(" output: %s", step.Output) + } + if step.Error != "" { + t.Logf(" ERROR: %s", step.Error) + } + }, + } + + ctx := context.Background() + instance, err := spritesExec.CreateInstance(ctx, req) + require.NoError(t, err) + defer func() { + t.Log("Cleaning up sprite instance...") + _ = spritesExec.StopInstance(context.Background(), instance, true) + }() + + // Verify agentctl health via port-forwarded proxy + localPort := instance.Metadata["local_port"].(int) + t.Logf("Checking agentctl health at http://127.0.0.1:%d/health ...", localPort) + + // Retry health check — proxy WebSocket setup may take a moment + var healthErr error + for i := 0; i < 5; i++ { + healthCtx, healthCancel := context.WithTimeout(ctx, 5*time.Second) + healthErr = instance.Client.Health(healthCtx) + healthCancel() + if healthErr == nil { + break + } + t.Logf(" health attempt %d failed: %v", i+1, healthErr) + time.Sleep(1 * time.Second) + } + require.NoError(t, healthErr) + t.Log("agentctl health: OK") + + // Verify agentctl status (may 404 if no instance is configured yet — that's OK) + statusCtx, statusCancel := context.WithTimeout(ctx, 10*time.Second) + defer statusCancel() + status, err := instance.Client.GetStatus(statusCtx) + if err != nil { + t.Logf("agentctl status: %v (expected — no instance configured)", err) + } else { + statusJSON, _ := json.MarshalIndent(status, "", " ") + t.Logf("agentctl status: %s", statusJSON) + } + + // --- Agentctl operation subtests --- + client := instance.Client + + t.Run("FileOps", func(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + // Use "." — paths are relative to instance workspace (/workspace) + tree, err := client.RequestFileTree(ctx, ".", 2) + require.NoError(t, err) + require.NotNil(t, tree.Root) + t.Logf("file tree root: %s, children: %d", tree.Root.Name, len(tree.Root.Children)) + + results, err := client.SearchFiles(ctx, "gin.go", 10) + require.NoError(t, err) + require.NotEmpty(t, results.Files) + t.Logf("found %d files matching 'gin.go'", len(results.Files)) + + content, err := client.RequestFileContent(ctx, "go.mod") + require.NoError(t, err) + require.Contains(t, content.Content, "github.com/gin-gonic/gin") + t.Logf("go.mod: %d bytes", len(content.Content)) + + _, err = client.CreateFile(ctx, "e2e-test.txt") + require.NoError(t, err) + _, err = client.DeleteFile(ctx, "e2e-test.txt") + require.NoError(t, err) + t.Log("file create+delete roundtrip OK") + }) + + t.Run("ConcurrentProcesses", func(t *testing.T) { + // Completed processes are immediately removed from tracking, + // so we use a workspace stream to capture output in real-time. + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + procClient := agentctl.NewClient("127.0.0.1", localPort, log) + defer procClient.Close() + + var procOutput strings.Builder + var procMu sync.Mutex + connected := make(chan struct{}) + + stream, err := procClient.StreamWorkspace(ctx, agentctl.WorkspaceStreamCallbacks{ + OnConnected: func() { close(connected) }, + OnProcessOutput: func(output *agentctl.ProcessOutput) { + procMu.Lock() + procOutput.WriteString(output.Data) + procMu.Unlock() + }, + }) + require.NoError(t, err) + defer stream.Close() + + select { + case <-connected: + case <-ctx.Done(): + t.Fatal("workspace stream connection timed out") + } + + markers := []string{ + "PROC_A_" + randomSuffix(), + "PROC_B_" + randomSuffix(), + "PROC_C_" + randomSuffix(), + } + + // Start all 3 concurrently + var wg sync.WaitGroup + var mu sync.Mutex + var startErrs []error + + for _, m := range markers { + wg.Add(1) + go func(marker string) { + defer wg.Done() + _, err := client.StartProcess(ctx, agentctl.StartProcessRequest{ + SessionID: "e2e-session", + Command: fmt.Sprintf("echo %s", marker), + }) + mu.Lock() + defer mu.Unlock() + if err != nil { + startErrs = append(startErrs, err) + } + }(m) + } + wg.Wait() + require.Empty(t, startErrs, "all processes should start without error") + + // Verify all markers appear in process output stream + for _, marker := range markers { + m := marker + require.Eventually(t, func() bool { + procMu.Lock() + defer procMu.Unlock() + return strings.Contains(procOutput.String(), m) + }, 10*time.Second, 200*time.Millisecond, + "process output should contain marker %s", m) + } + t.Logf("all %d concurrent processes produced output via stream", len(markers)) + }) + + t.Run("WorkspaceStreamShellIO", func(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + // Ensure shell is started before connecting stream + require.NoError(t, client.StartShell(ctx)) + + var shellOutput strings.Builder + var shellMu sync.Mutex + connected := make(chan struct{}) + + shellClient := agentctl.NewClient("127.0.0.1", localPort, log) + defer shellClient.Close() + + stream, err := shellClient.StreamWorkspace(ctx, agentctl.WorkspaceStreamCallbacks{ + OnConnected: func() { close(connected) }, + OnShellOutput: func(data string) { + shellMu.Lock() + shellOutput.WriteString(data) + shellMu.Unlock() + }, + }) + require.NoError(t, err) + defer stream.Close() + + select { + case <-connected: + case <-ctx.Done(): + t.Fatal("workspace stream connection timed out") + } + + marker := "E2E_SHELL_MARKER_" + randomSuffix() + err = stream.WriteShellInput(fmt.Sprintf("echo %s\n", marker)) + require.NoError(t, err) + + require.Eventually(t, func() bool { + shellMu.Lock() + defer shellMu.Unlock() + return strings.Contains(shellOutput.String(), marker) + }, 10*time.Second, 200*time.Millisecond, "shell output should contain marker") + t.Logf("shell PTY roundtrip confirmed with marker: %s", marker) + }) + + t.Run("MultipleWebSocketStreams", func(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + localPort := instance.Metadata["local_port"].(int) + + client2 := agentctl.NewClient("127.0.0.1", localPort, log) + defer client2.Close() + + require.NoError(t, client2.Health(ctx)) + t.Log("second client health: OK") + + var output2 strings.Builder + var mu2 sync.Mutex + connected2 := make(chan struct{}) + + stream2, err := client2.StreamWorkspace(ctx, agentctl.WorkspaceStreamCallbacks{ + OnConnected: func() { close(connected2) }, + OnProcessOutput: func(output *agentctl.ProcessOutput) { + mu2.Lock() + output2.WriteString(output.Data) + mu2.Unlock() + }, + }) + require.NoError(t, err) + defer stream2.Close() + + select { + case <-connected2: + case <-ctx.Done(): + t.Fatal("second workspace stream connection timed out") + } + + marker := "MULTI_WS_" + randomSuffix() + proc, err := client.StartProcess(ctx, agentctl.StartProcessRequest{ + SessionID: "e2e-session", + Command: fmt.Sprintf("echo %s", marker), + }) + require.NoError(t, err) + + require.Eventually(t, func() bool { + mu2.Lock() + defer mu2.Unlock() + return strings.Contains(output2.String(), marker) + }, 10*time.Second, 200*time.Millisecond, + "second workspace stream should receive process output") + t.Logf("multi-stream process %s: output confirmed on stream 2", proc.ID) + }) + + t.Run("GitOps", func(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + // Configure git user (fire-and-forget — process completes instantly) + _, err := client.StartProcess(ctx, agentctl.StartProcessRequest{ + SessionID: "e2e-session", + Command: "cd /workspace && git config user.email 'e2e@test.com' && git config user.name 'E2E Test'", + }) + require.NoError(t, err) + time.Sleep(2 * time.Second) // let config complete + + // Create a file, stage, commit via git API + _, err = client.CreateFile(ctx, "e2e-change.txt") + require.NoError(t, err) + + result, err := client.GitStage(ctx, []string{"e2e-change.txt"}) + require.NoError(t, err) + require.True(t, result.Success, "git stage should succeed: %s", result.Error) + + result, err = client.GitCommit(ctx, "e2e test commit", false) + require.NoError(t, err) + require.True(t, result.Success, "git commit should succeed: %s", result.Error) + t.Logf("git commit: %s", result.Output) + }) + + t.Run("VscodeStartAndReachable", func(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, 120*time.Second) + defer cancel() + + resp, err := client.StartVscode(ctx, "dark") + require.NoError(t, err) + require.True(t, resp.Success, "vscode start should succeed: %s", resp.Error) + t.Log("vscode start requested") + + var vscodePort int + require.Eventually(t, func() bool { + st, err := client.VscodeStatus(ctx) + if err != nil { + return false + } + t.Logf("vscode status: %s port=%d", st.Status, st.Port) + if st.Status == "running" && st.Port > 0 { + vscodePort = st.Port + return true + } + return false + }, 90*time.Second, 3*time.Second, "vscode should reach running status") + t.Logf("vscode running on port %d inside sprite", vscodePort) + + // Verify VS Code is serving by curling from inside the sprite. + // Use sleep to keep process alive (completed processes are removed + // from tracking immediately, so GetProcess needs an alive process). + curlCmd := fmt.Sprintf( + "curl -sL http://localhost:%d/ > /tmp/vscode-curl.out 2>&1; sleep 60", vscodePort) + proc, err := client.StartProcess(ctx, agentctl.StartProcessRequest{ + SessionID: "e2e-session", + Command: curlCmd, + }) + require.NoError(t, err) + + // Wait for curl to write output, then read via a second process + time.Sleep(5 * time.Second) + catProc, err := client.StartProcess(ctx, agentctl.StartProcessRequest{ + SessionID: "e2e-session", + Command: "cat /tmp/vscode-curl.out; sleep 60", + }) + require.NoError(t, err) + time.Sleep(2 * time.Second) + + info, err := client.GetProcess(ctx, catProc.ID, true) + require.NoError(t, err) + var htmlOutput string + for _, chunk := range info.Output { + htmlOutput += chunk.Data + } + t.Logf("vscode HTML preview: %.200s", htmlOutput) + require.True(t, + strings.Contains(strings.ToLower(htmlOutput), "html") || + strings.Contains(strings.ToLower(htmlOutput), "doctype"), + "vscode should serve HTML content") + + // Clean up long-running processes + _ = client.StopProcess(ctx, proc.ID) + _ = client.StopProcess(ctx, catProc.ID) + + err = client.StopVscode(ctx) + require.NoError(t, err) + t.Log("vscode stopped") + }) + + // Interactive mode: block so user can "sprites attach " + if *spritesInteractive { + spriteName, _ := instance.Metadata["sprite_name"].(string) + t.Logf("\n Sprite ready! Attach with: sprites attach %s", spriteName) + t.Log(" Press Ctrl+C to destroy and exit.") + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + <-sig + t.Log("Interrupted, cleaning up...") + } +} + +// noopSecretStore is a minimal stub — sprites executor doesn't use it directly. +type noopSecretStore struct{} + +func (n *noopSecretStore) Create(_ context.Context, _ *secrets.SecretWithValue) error { return nil } +func (n *noopSecretStore) Get(_ context.Context, _ string) (*secrets.Secret, error) { + return nil, nil +} +func (n *noopSecretStore) Reveal(_ context.Context, _ string) (string, error) { return "", nil } +func (n *noopSecretStore) Update(_ context.Context, _ string, _ *secrets.UpdateSecretRequest) error { + return nil +} +func (n *noopSecretStore) Delete(_ context.Context, _ string) error { return nil } +func (n *noopSecretStore) List(_ context.Context) ([]*secrets.SecretListItem, error) { + return nil, nil +} +func (n *noopSecretStore) Close() error { return nil } + +func randomSuffix() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/executor_standalone.go similarity index 80% rename from apps/backend/internal/agent/lifecycle/runtime_standalone.go rename to apps/backend/internal/agent/lifecycle/executor_standalone.go index 4d0b424f4d..222def2713 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/executor_standalone.go @@ -7,15 +7,15 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" ) -// StandaloneRuntime implements Runtime for standalone agentctl execution. +// StandaloneExecutor implements Runtime for standalone agentctl execution. // In this mode, a single agentctl control server manages multiple agent instances. -type StandaloneRuntime struct { +type StandaloneExecutor struct { ctl *agentctl.ControlClient host string port int @@ -23,9 +23,9 @@ type StandaloneRuntime struct { interactiveRunner *process.InteractiveRunner } -// NewStandaloneRuntime creates a new standalone runtime. -func NewStandaloneRuntime(ctl *agentctl.ControlClient, host string, port int, log *logger.Logger) *StandaloneRuntime { - return &StandaloneRuntime{ +// NewStandaloneExecutor creates a new standalone runtime. +func NewStandaloneExecutor(ctl *agentctl.ControlClient, host string, port int, log *logger.Logger) *StandaloneExecutor { + return &StandaloneExecutor{ ctl: ctl, host: host, port: port, @@ -33,15 +33,15 @@ func NewStandaloneRuntime(ctl *agentctl.ControlClient, host string, port int, lo } } -func (r *StandaloneRuntime) Name() runtime.Name { - return runtime.NameStandalone +func (r *StandaloneExecutor) Name() executor.Name { + return executor.NameStandalone } -func (r *StandaloneRuntime) HealthCheck(ctx context.Context) error { +func (r *StandaloneExecutor) HealthCheck(ctx context.Context) error { return r.ctl.Health(ctx) } -func (r *StandaloneRuntime) waitForReady(ctx context.Context) error { +func (r *StandaloneExecutor) waitForReady(ctx context.Context) error { if err := r.ctl.Health(ctx); err == nil { return nil } @@ -68,7 +68,7 @@ func (r *StandaloneRuntime) waitForReady(ctx context.Context) error { } } -func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) { +func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { if err := r.waitForReady(ctx); err != nil { return nil, err } @@ -144,7 +144,7 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea zap.Int("port", resp.Port), zap.String("workspace", req.WorkspacePath)) - return &RuntimeInstance{ + return &ExecutorInstance{ InstanceID: req.InstanceID, TaskID: req.TaskID, SessionID: req.SessionID, @@ -157,7 +157,7 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea }, nil } -func (r *StandaloneRuntime) StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error { +func (r *StandaloneExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { if instance.StandaloneInstanceID == "" { return nil // No standalone instance to stop } @@ -169,18 +169,18 @@ func (r *StandaloneRuntime) StopInstance(ctx context.Context, instance *RuntimeI return nil } -func (r *StandaloneRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) { +func (r *StandaloneExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { // Standalone instances are not persisted - they are transient processes // managed by agentctl. Session resume will restart them as needed. return nil, nil } // SetInteractiveRunner sets the interactive runner for passthrough mode. -func (r *StandaloneRuntime) SetInteractiveRunner(runner *process.InteractiveRunner) { +func (r *StandaloneExecutor) SetInteractiveRunner(runner *process.InteractiveRunner) { r.interactiveRunner = runner } // GetInteractiveRunner returns the interactive runner for passthrough mode. -func (r *StandaloneRuntime) GetInteractiveRunner() *process.InteractiveRunner { +func (r *StandaloneExecutor) GetInteractiveRunner() *process.InteractiveRunner { return r.interactiveRunner } diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 1c8608b4d5..a6d37d89b0 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -15,16 +15,16 @@ import ( "github.com/kandev/kandev/internal/worktree" ) -// RuntimeFallbackPolicy controls behavior when a requested runtime is unavailable. -type RuntimeFallbackPolicy string +// ExecutorFallbackPolicy controls behavior when a requested runtime is unavailable. +type ExecutorFallbackPolicy string const ( - // RuntimeFallbackAllow silently falls back to the default runtime (current behavior). - RuntimeFallbackAllow RuntimeFallbackPolicy = "allow" - // RuntimeFallbackWarn falls back but logs a warning (current behavior, explicit). - RuntimeFallbackWarn RuntimeFallbackPolicy = "warn" - // RuntimeFallbackDeny returns an error if the requested runtime is unavailable. - RuntimeFallbackDeny RuntimeFallbackPolicy = "deny" + // ExecutorFallbackAllow silently falls back to the default runtime (current behavior). + ExecutorFallbackAllow ExecutorFallbackPolicy = "allow" + // ExecutorFallbackWarn falls back but logs a warning (current behavior, explicit). + ExecutorFallbackWarn ExecutorFallbackPolicy = "warn" + // ExecutorFallbackDeny returns an error if the requested runtime is unavailable. + ExecutorFallbackDeny ExecutorFallbackPolicy = "deny" ) // Manager manages agent instance lifecycles @@ -37,12 +37,12 @@ type Manager struct { mcpProvider McpConfigProvider logger *logger.Logger - // RuntimeRegistry manages multiple runtimes (Docker, Standalone, etc.) + // ExecutorRegistry manages multiple runtimes (Docker, Standalone, etc.) // Each task can select its runtime based on executor type. - runtimeRegistry *RuntimeRegistry + executorRegistry *ExecutorRegistry - // runtimeFallbackPolicy controls behavior when a requested runtime is unavailable. - runtimeFallbackPolicy RuntimeFallbackPolicy + // executorFallbackPolicy controls behavior when a requested runtime is unavailable. + executorFallbackPolicy ExecutorFallbackPolicy // Refactored components for separation of concerns executionStore *ExecutionStore // Thread-safe execution tracking @@ -59,6 +59,9 @@ type Manager struct { // bootMessageService creates boot messages displayed in chat during agent startup. bootMessageService BootMessageService + // preparerRegistry maps executor types to environment preparers. + preparerRegistry *PreparerRegistry + // mcpHandler is the MCP request dispatcher for handling MCP requests // from agentctl instances through the agent stream. mcpHandler agentctl.MCPHandler @@ -70,18 +73,18 @@ type Manager struct { } // NewManager creates a new lifecycle manager. -// The runtimeRegistry manages multiple runtimes (Docker, Standalone, etc.) for task-specific execution. +// The executorRegistry manages multiple runtimes (Docker, Standalone, etc.) for task-specific execution. // The containerManager parameter is optional and used for Docker cleanup (pass nil for non-Docker runtimes). // The fallbackPolicy controls behavior when a requested runtime is unavailable. func NewManager( reg *registry.Registry, eventBus bus.EventBus, - runtimeRegistry *RuntimeRegistry, + executorRegistry *ExecutorRegistry, containerManager *ContainerManager, credsMgr CredentialsManager, profileResolver ProfileResolver, mcpProvider McpConfigProvider, - fallbackPolicy RuntimeFallbackPolicy, + fallbackPolicy ExecutorFallbackPolicy, log *logger.Logger, ) *Manager { componentLogger := log.WithFields(zap.String("component", "lifecycle-manager")) @@ -108,22 +111,22 @@ func NewManager( } mgr := &Manager{ - registry: reg, - eventBus: eventBus, - runtimeRegistry: runtimeRegistry, - runtimeFallbackPolicy: fallbackPolicy, - credsMgr: credsMgr, - profileResolver: profileResolver, - mcpProvider: mcpProvider, - logger: componentLogger, - executionStore: executionStore, - commandBuilder: commandBuilder, - sessionManager: sessionManager, - eventPublisher: eventPublisher, - containerManager: containerManager, - historyManager: historyManager, - cleanupInterval: 30 * time.Second, - stopCh: stopCh, + registry: reg, + eventBus: eventBus, + executorRegistry: executorRegistry, + executorFallbackPolicy: fallbackPolicy, + credsMgr: credsMgr, + profileResolver: profileResolver, + mcpProvider: mcpProvider, + logger: componentLogger, + executionStore: executionStore, + commandBuilder: commandBuilder, + sessionManager: sessionManager, + eventPublisher: eventPublisher, + containerManager: containerManager, + historyManager: historyManager, + cleanupInterval: 30 * time.Second, + stopCh: stopCh, } // Initialize stream manager with callbacks that delegate to manager methods @@ -143,8 +146,8 @@ func NewManager( // Set session manager dependencies for full orchestration sessionManager.SetDependencies(eventPublisher, mgr.streamManager, executionStore, historyManager) - if runtimeRegistry != nil { - mgr.logger.Info("initialized with runtimes", zap.Int("count", len(runtimeRegistry.List()))) + if executorRegistry != nil { + mgr.logger.Info("initialized with runtimes", zap.Int("count", len(executorRegistry.List()))) } return mgr @@ -198,3 +201,8 @@ func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) { func (m *Manager) SetBootMessageService(svc BootMessageService) { m.bootMessageService = svc } + +// SetPreparerRegistry sets the registry of environment preparers. +func (m *Manager) SetPreparerRegistry(registry *PreparerRegistry) { + m.preparerRegistry = registry +} diff --git a/apps/backend/internal/agent/lifecycle/manager_events_test.go b/apps/backend/internal/agent/lifecycle/manager_events_test.go index f0aa3806d5..ff8a892bce 100644 --- a/apps/backend/internal/agent/lifecycle/manager_events_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_events_test.go @@ -66,7 +66,7 @@ func createTestManagerWithTracking() (*Manager, *MockEventBusWithTracking) { eventBus := &MockEventBusWithTracking{} credsMgr := &MockCredentialsManager{} profileResolver := &MockProfileResolver{} - mgr := NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, RuntimeFallbackWarn, log) + mgr := NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, log) return mgr, eventBus } diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index 4edd8d56ba..1254d042ea 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -195,7 +195,7 @@ func (m *Manager) verifyPassthroughEnabled(ctx context.Context, sessionID, profi // 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() + rt, err := m.getDefaultExecutorBackend() if err != nil { return nil, fmt.Errorf("no runtime configured: %w", err) } @@ -211,7 +211,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work return nil, fmt.Errorf("agent type %q not found in registry", info.AgentID) } - req := &RuntimeCreateRequest{ + req := &ExecutorCreateRequest{ InstanceID: executionID, TaskID: taskID, SessionID: info.SessionID, diff --git a/apps/backend/internal/agent/lifecycle/manager_runtime.go b/apps/backend/internal/agent/lifecycle/manager_executor.go similarity index 57% rename from apps/backend/internal/agent/lifecycle/manager_runtime.go rename to apps/backend/internal/agent/lifecycle/manager_executor.go index 0b5c244bc0..63e9134297 100644 --- a/apps/backend/internal/agent/lifecycle/manager_runtime.go +++ b/apps/backend/internal/agent/lifecycle/manager_executor.go @@ -5,34 +5,34 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" "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 { +// getExecutorBackend returns the appropriate runtime for the given executor type. +// If the executor type is empty or the runtime is not available, behavior depends on executorFallbackPolicy. +func (m *Manager) getExecutorBackend(executorType string) (ExecutorBackend, error) { + if m.executorRegistry == nil { return nil, fmt.Errorf("no runtime registry configured") } if executorType != "" { - runtimeName := runtime.ExecutorTypeToRuntime(models.ExecutorType(executorType)) - rt, err := m.runtimeRegistry.GetRuntime(runtimeName) + runtimeName := executor.ExecutorTypeToBackend(models.ExecutorType(executorType)) + rt, err := m.executorRegistry.GetBackend(runtimeName) if err == nil { return rt, nil } // Handle fallback based on policy - switch m.runtimeFallbackPolicy { - case RuntimeFallbackDeny: + switch m.executorFallbackPolicy { + case ExecutorFallbackDeny: return nil, fmt.Errorf("runtime %s not available and fallback is denied: %w", runtimeName, err) - case RuntimeFallbackWarn: + case ExecutorFallbackWarn: 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: + case ExecutorFallbackAllow: m.logger.Debug("requested runtime not available, falling back to default", zap.String("executor_type", executorType), zap.String("runtime", string(runtimeName))) @@ -45,14 +45,14 @@ func (m *Manager) getRuntimeForExecutorType(executorType string) (Runtime, error } } - return m.runtimeRegistry.GetRuntime(runtime.NameStandalone) + return m.executorRegistry.GetBackend(executor.NameStandalone) } -// getDefaultRuntime returns the default runtime (standalone). +// getDefaultExecutorBackend returns the default runtime (standalone). // This is used when no executor type is specified. -func (m *Manager) getDefaultRuntime() (Runtime, error) { - if m.runtimeRegistry == nil { +func (m *Manager) getDefaultExecutorBackend() (ExecutorBackend, error) { + if m.executorRegistry == nil { return nil, fmt.Errorf("no runtime registry configured") } - return m.runtimeRegistry.GetRuntime(runtime.NameStandalone) + return m.executorRegistry.GetBackend(executor.NameStandalone) } diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction.go b/apps/backend/internal/agent/lifecycle/manager_interaction.go index 1744a4b39d..6bf644381f 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/events" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -92,7 +92,7 @@ func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool) } // Stop the agent execution via the runtime that created it - m.stopAgentViaRuntime(ctx, executionID, execution, force) + m.stopAgentViaBackend(ctx, executionID, execution, force) // Update execution status and remove from tracking _ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) { @@ -390,12 +390,12 @@ func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID 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 { +// stopAgentViaBackend stops the agent execution via the runtime that created it. +func (m *Manager) stopAgentViaBackend(ctx context.Context, executionID string, execution *AgentExecution, force bool) { + if execution.RuntimeName == "" || m.executorRegistry == nil { return } - rt, err := m.runtimeRegistry.GetRuntime(runtime.Name(execution.RuntimeName)) + rt, err := m.executorRegistry.GetBackend(executor.Name(execution.RuntimeName)) if err != nil { m.logger.Warn("failed to get runtime for stopping execution", zap.String("execution_id", executionID), @@ -404,7 +404,7 @@ func (m *Manager) stopAgentViaRuntime(ctx context.Context, executionID string, e return } m.stopPassthroughProcess(ctx, executionID, execution, rt) - runtimeInstance := &RuntimeInstance{ + runtimeInstance := &ExecutorInstance{ InstanceID: execution.ID, TaskID: execution.TaskID, ContainerID: execution.ContainerID, @@ -419,7 +419,7 @@ func (m *Manager) stopAgentViaRuntime(ctx context.Context, executionID string, e } // stopPassthroughProcess stops the passthrough interactive process if one is running. -func (m *Manager) stopPassthroughProcess(ctx context.Context, executionID string, execution *AgentExecution, rt Runtime) { +func (m *Manager) stopPassthroughProcess(ctx context.Context, executionID string, execution *AgentExecution, rt ExecutorBackend) { if execution.PassthroughProcessID == "" { return } diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 0c62092421..d479ad6d0a 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" ) @@ -48,6 +49,16 @@ func buildLaunchMetadata(req *LaunchRequest, mainRepoGitDir, worktreeID, worktre if worktreeBranch != "" { metadata[MetadataKeyWorktreeBranch] = worktreeBranch } + // Pass repo info for remote executors (Sprites, remote docker, etc.) + if req.RepositoryPath != "" { + metadata[MetadataKeyRepositoryPath] = req.RepositoryPath + } + if req.SetupScript != "" { + metadata[MetadataKeySetupScript] = req.SetupScript + } + if req.BaseBranch != "" { + metadata[MetadataKeyBaseBranch] = req.BaseBranch + } return metadata } @@ -143,15 +154,15 @@ func (m *Manager) launchPrepareRequest(req *LaunchRequest, profileInfo *AgentPro return reqWithWorktree, executionID } -// launchBuildRuntimeRequest resolves MCP servers, builds the RuntimeCreateRequest, +// launchBuildExecutorRequest resolves MCP servers, builds the ExecutorCreateRequest, // 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) +func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID string, reqWithWorktree *LaunchRequest, agentConfig agents.Agent, mainRepoGitDir, worktreeID, worktreeBranch string) (*ExecutorCreateRequest, *ExecutorInstance, ExecutorBackend, error) { + rt, err := m.getExecutorBackend(reqWithWorktree.ExecutorType) if err != nil { return nil, nil, nil, fmt.Errorf("no runtime configured: %w", err) } - env := m.buildEnvForRuntime(executionID, reqWithWorktree, agentConfig) + env := m.buildEnvForExecution(executionID, reqWithWorktree, agentConfig) acpMcpServers, err := m.resolveMcpServersWithParams(ctx, reqWithWorktree.AgentProfileID, reqWithWorktree.Metadata, agentConfig) if err != nil { @@ -171,7 +182,7 @@ func (m *Manager) launchBuildRuntimeRequest(ctx context.Context, executionID str metadata := buildLaunchMetadata(reqWithWorktree, mainRepoGitDir, worktreeID, worktreeBranch) - runtimeReq := &RuntimeCreateRequest{ + execReq := &ExecutorCreateRequest{ InstanceID: executionID, TaskID: reqWithWorktree.TaskID, SessionID: reqWithWorktree.SessionID, @@ -182,13 +193,86 @@ func (m *Manager) launchBuildRuntimeRequest(ctx context.Context, executionID str Metadata: metadata, AgentConfig: agentConfig, McpServers: mcpServers, + OnProgress: func(step PrepareStep, stepIndex int, totalSteps int) { + m.eventPublisher.PublishPrepareProgress(reqWithWorktree.SessionID, &PrepareProgressEventPayload{ + TaskID: reqWithWorktree.TaskID, + SessionID: reqWithWorktree.SessionID, + StepName: step.Name, + StepIndex: stepIndex, + TotalSteps: totalSteps, + Status: string(step.Status), + Output: step.Output, + Error: step.Error, + }) + }, } - runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq) + execInstance, err := rt.CreateInstance(ctx, execReq) if err != nil { return nil, nil, nil, fmt.Errorf("failed to create execution: %w", err) } - return runtimeReq, runtimeInstance, rt, nil + return execReq, execInstance, rt, nil +} + +// runEnvironmentPreparer runs the environment preparer for the executor type, if one is registered. +// Preparation failures are non-fatal and logged as warnings. +func (m *Manager) runEnvironmentPreparer(ctx context.Context, req *LaunchRequest, workspacePath string) { + if m.preparerRegistry == nil { + return + } + execName := executor.ExecutorTypeToBackend(models.ExecutorType(req.ExecutorType)) + preparer := m.preparerRegistry.Get(execName) + if preparer == nil { + return + } + + prepReq := &EnvPrepareRequest{ + TaskID: req.TaskID, + SessionID: req.SessionID, + ExecutorType: execName, + WorkspacePath: workspacePath, + RepositoryPath: req.RepositoryPath, + UseWorktree: req.UseWorktree, + SetupScript: req.SetupScript, + Env: req.Env, + } + + onProgress := func(step PrepareStep, stepIndex int, totalSteps int) { + m.eventPublisher.PublishPrepareProgress(req.SessionID, &PrepareProgressEventPayload{ + TaskID: req.TaskID, + SessionID: req.SessionID, + StepName: step.Name, + StepIndex: stepIndex, + TotalSteps: totalSteps, + Status: string(step.Status), + Output: step.Output, + Error: step.Error, + }) + } + + result, err := preparer.Prepare(ctx, prepReq, onProgress) + if err != nil { + m.logger.Warn("environment preparation failed", + zap.String("task_id", req.TaskID), + zap.String("preparer", preparer.Name()), + zap.Error(err)) + m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{ + TaskID: req.TaskID, + SessionID: req.SessionID, + Success: false, + ErrorMessage: err.Error(), + }) + return + } + + m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{ + TaskID: req.TaskID, + SessionID: req.SessionID, + Success: result.Success, + ErrorMessage: result.ErrorMessage, + DurationMs: result.Duration.Milliseconds(), + WorkspacePath: result.WorkspacePath, + }) } // Launch launches a new agent for a task @@ -223,17 +307,20 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti // 4. Resolve workspace path (with optional worktree creation) workspacePath, mainRepoGitDir, worktreeID, worktreeBranch := m.launchResolveWorkspacePath(ctx, req) + // 4b. Run environment preparation (if preparer registered for this executor type) + m.runEnvironmentPreparer(ctx, req, workspacePath) + // 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) + execReq, execInstance, rt, err := m.launchBuildExecutorRequest(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 := execInstance.ToAgentExecution(execReq) execution.RuntimeName = string(rt.Name()) if req.ACPSessionID != "" { diff --git a/apps/backend/internal/agent/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/lifecycle/manager_lifecycle.go index 87eedb6e1f..a1e1986067 100644 --- a/apps/backend/internal/agent/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/lifecycle/manager_lifecycle.go @@ -9,7 +9,7 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agentctl/tracing" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -19,16 +19,16 @@ const containerStateExited = "exited" // Start starts the lifecycle manager background tasks func (m *Manager) Start(ctx context.Context) error { - if m.runtimeRegistry == nil { + if m.executorRegistry == nil { m.logger.Warn("no runtime registry configured") return nil } - runtimeNames := m.runtimeRegistry.List() + runtimeNames := m.executorRegistry.List() m.logger.Info("starting lifecycle manager", zap.Int("runtimes", len(runtimeNames))) // Check health of all registered runtimes - healthResults := m.runtimeRegistry.HealthCheckAll(ctx) + healthResults := m.executorRegistry.HealthCheckAll(ctx) for name, err := range healthResults { if err != nil { m.logger.Warn("runtime health check failed", @@ -40,7 +40,7 @@ func (m *Manager) Start(ctx context.Context) error { } // Try to recover executions from all runtimes - recovered, err := m.runtimeRegistry.RecoverAll(ctx) + recovered, err := m.executorRegistry.RecoverAll(ctx) if err != nil { m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err)) } @@ -94,8 +94,13 @@ func (m *Manager) Start(ctx context.Context) error { m.logger.Info("cleanup loop started") } + // Start health check loop for registered executor backends + m.wg.Add(1) + go m.healthCheckLoop(ctx) + m.logger.Info("health check loop started") + // Set up callbacks for passthrough mode (using standalone runtime) - if standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone); err == nil { + if standaloneRT, err := m.executorRegistry.GetBackend(executor.NameStandalone); err == nil { if interactiveRunner := standaloneRT.GetInteractiveRunner(); interactiveRunner != nil { // Turn complete callback interactiveRunner.SetTurnCompleteCallback(func(sessionID string) { @@ -146,6 +151,39 @@ func (m *Manager) Stop() error { return nil } +// healthCheckLoop periodically checks the health of all registered executor backends. +func (m *Manager) healthCheckLoop(ctx context.Context) { + defer m.wg.Done() + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + for { + select { + case <-m.stopCh: + return + case <-ctx.Done(): + return + case <-ticker.C: + m.runHealthChecks(ctx) + } + } +} + +// runHealthChecks runs health checks on all registered executor backends. +func (m *Manager) runHealthChecks(ctx context.Context) { + if m.executorRegistry == nil { + return + } + results := m.executorRegistry.HealthCheckAll(ctx) + for name, err := range results { + if err != nil { + m.logger.Warn("executor backend health check failed", + zap.String("executor", string(name)), + zap.Error(err)) + } + } +} + // StopAllAgents attempts a graceful shutdown of all active agents concurrently. func (m *Manager) StopAllAgents(ctx context.Context) error { executions := m.executionStore.List() diff --git a/apps/backend/internal/agent/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/lifecycle/manager_passthrough.go index e0da92e21b..15528dca04 100644 --- a/apps/backend/internal/agent/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/lifecycle/manager_passthrough.go @@ -9,7 +9,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" - "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/executor" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/process" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" @@ -564,10 +564,10 @@ func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agent // 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 { + if m.executorRegistry == nil { return nil } - standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone) + standaloneRT, err := m.executorRegistry.GetBackend(executor.NameStandalone) if err != nil { return nil } diff --git a/apps/backend/internal/agent/lifecycle/manager_profile.go b/apps/backend/internal/agent/lifecycle/manager_profile.go index 9915c42471..b4d806a3da 100644 --- a/apps/backend/internal/agent/lifecycle/manager_profile.go +++ b/apps/backend/internal/agent/lifecycle/manager_profile.go @@ -9,8 +9,8 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agent/mcpconfig" - "github.com/kandev/kandev/internal/agent/runtime" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" ) @@ -114,7 +114,7 @@ func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID str } // Get default runtime for MCP policy (used before execution exists) - defaultRT, _ := m.getDefaultRuntime() + defaultRT, _ := m.getDefaultExecutorBackend() policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(defaultRT)) executorID := "" @@ -137,9 +137,9 @@ func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID str return mcpconfig.ToACPServers(resolved), nil } -func runtimeName(rt Runtime) runtime.Name { +func runtimeName(rt ExecutorBackend) executor.Name { if rt == nil { - return runtime.NameUnknown + return executor.NameUnknown } return rt.Name() } diff --git a/apps/backend/internal/agent/lifecycle/manager_startup.go b/apps/backend/internal/agent/lifecycle/manager_startup.go index 17b8c3b61d..3fd21d0761 100644 --- a/apps/backend/internal/agent/lifecycle/manager_startup.go +++ b/apps/backend/internal/agent/lifecycle/manager_startup.go @@ -143,9 +143,9 @@ func (m *Manager) finalizeBootMessage(execution *AgentExecution, msg *models.Mes } } -// buildEnvForRuntime builds environment variables for any runtime. +// buildEnvForExecution 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 { +func (m *Manager) buildEnvForExecution(executionID string, req *LaunchRequest, agentConfig agents.Agent) map[string]string { env := make(map[string]string) // Copy request environment diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index 334ac85115..c7c7b7849d 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -206,7 +206,7 @@ func newTestManager() *Manager { credsMgr := &MockCredentialsManager{} profileResolver := &MockProfileResolver{} // Pass nil for runtime and containerManager - tests don't need them - return NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, RuntimeFallbackWarn, log) + return NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, log) } func TestNewManager(t *testing.T) { diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 1c162632c6..63175f46c1 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -172,6 +172,9 @@ type LaunchRequest struct { ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) + // Environment preparation + SetupScript string // Setup script to run before agent starts + // Worktree configuration UseWorktree bool // Whether to use a Git worktree for isolation RepositoryID string // Repository ID for worktree tracking diff --git a/apps/backend/internal/agent/mcpconfig/policy.go b/apps/backend/internal/agent/mcpconfig/policy.go index fe096ecb6c..f4997bd165 100644 --- a/apps/backend/internal/agent/mcpconfig/policy.go +++ b/apps/backend/internal/agent/mcpconfig/policy.go @@ -1,13 +1,13 @@ package mcpconfig -import "github.com/kandev/kandev/internal/agent/runtime" +import "github.com/kandev/kandev/internal/agent/executor" // DefaultPolicyForRuntime returns the baseline MCP policy for a runtime. // We only enable local runtimes today; non-local runtimes default to deny-all // until we add explicit executor policies for their networks. -func DefaultPolicyForRuntime(runtimeName runtime.Name) Policy { +func DefaultPolicyForRuntime(runtimeName executor.Name) Policy { switch runtimeName { - case runtime.NameLocal, runtime.NameStandalone, runtime.NameDocker: + case executor.NameLocal, executor.NameStandalone, executor.NameDocker: return Policy{ AllowStdio: true, AllowHTTP: true, diff --git a/apps/backend/internal/agentctl/tracing/executor.go b/apps/backend/internal/agentctl/tracing/executor.go new file mode 100644 index 0000000000..09d17823c1 --- /dev/null +++ b/apps/backend/internal/agentctl/tracing/executor.go @@ -0,0 +1,87 @@ +package tracing + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +const executorTracerName = "kandev-executor" + +func executorTracer() trace.Tracer { + return Tracer(executorTracerName) +} + +// TraceExecutorPrepare creates a span for environment preparation. +func TraceExecutorPrepare(ctx context.Context, taskID, sessionID, executorType string) (context.Context, trace.Span) { + ctx, span := executorTracer().Start(ctx, "executor.prepare", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("task_id", taskID), + attribute.String("session_id", sessionID), + attribute.String("executor_type", executorType), + ) + return ctx, span +} + +// TraceExecutorPrepareStep creates a child span for a single preparation step. +func TraceExecutorPrepareStep(ctx context.Context, stepName string, stepIndex, totalSteps int) (context.Context, trace.Span) { + ctx, span := executorTracer().Start(ctx, "executor.prepare.step", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("step_name", stepName), + attribute.Int("step_index", stepIndex), + attribute.Int("total_steps", totalSteps), + ) + return ctx, span +} + +// TraceExecutorPrepareStepResult records the result of a preparation step on its span. +func TraceExecutorPrepareStepResult(span trace.Span, status string, err error) { + span.SetAttributes(attribute.String("status", status)) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } +} + +// TraceExecutorCreateInstance creates a span for executor instance creation. +func TraceExecutorCreateInstance(ctx context.Context, executionID, taskID, executorType string) (context.Context, trace.Span) { + ctx, span := executorTracer().Start(ctx, "executor.create_instance", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("execution_id", executionID), + attribute.String("task_id", taskID), + attribute.String("executor_type", executorType), + ) + return ctx, span +} + +// TraceExecutorHealthCheck creates a span for an executor health check. +func TraceExecutorHealthCheck(ctx context.Context, executorName string) (context.Context, trace.Span) { + ctx, span := executorTracer().Start(ctx, "executor.health_check", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("executor_name", executorName), + ) + return ctx, span +} + +// TraceExecutorStop creates a span for executor instance stop. +func TraceExecutorStop(ctx context.Context, executionID, taskID string, force bool) (context.Context, trace.Span) { + ctx, span := executorTracer().Start(ctx, "executor.stop", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("execution_id", executionID), + attribute.String("task_id", taskID), + attribute.Bool("force", force), + ) + return ctx, span +} diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 440842c566..545a64dcdf 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -73,6 +73,19 @@ const ( ExecutorDeleted = "executor.deleted" ) +// Event types for executor profiles +const ( + ExecutorProfileCreated = "executor.profile.created" + ExecutorProfileUpdated = "executor.profile.updated" + ExecutorProfileDeleted = "executor.profile.deleted" +) + +// Event types for executor preparation +const ( + ExecutorPrepareProgress = "executor.prepare.progress" + ExecutorPrepareCompleted = "executor.prepare.completed" +) + // Event types for users const ( UserSettingsUpdated = "user.settings.updated" diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 339632559c..906a7d0f24 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -47,6 +47,11 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.ExecutorCreated, ws.ActionExecutorCreated) b.subscribe(eventBus, events.ExecutorUpdated, ws.ActionExecutorUpdated) b.subscribe(eventBus, events.ExecutorDeleted, ws.ActionExecutorDeleted) + b.subscribe(eventBus, events.ExecutorProfileCreated, ws.ActionExecutorProfileCreated) + b.subscribe(eventBus, events.ExecutorProfileUpdated, ws.ActionExecutorProfileUpdated) + b.subscribe(eventBus, events.ExecutorProfileDeleted, ws.ActionExecutorProfileDeleted) + b.subscribe(eventBus, events.ExecutorPrepareProgress, ws.ActionExecutorPrepareProgress) + b.subscribe(eventBus, events.ExecutorPrepareCompleted, ws.ActionExecutorPrepareCompleted) b.subscribe(eventBus, events.EnvironmentCreated, ws.ActionEnvironmentCreated) b.subscribe(eventBus, events.EnvironmentUpdated, ws.ActionEnvironmentUpdated) b.subscribe(eventBus, events.EnvironmentDeleted, ws.ActionEnvironmentDeleted) @@ -120,6 +125,11 @@ func (b *TaskEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action b.hub.BroadcastToSession(sessionID, msg) return nil } + case ws.ActionExecutorPrepareProgress, ws.ActionExecutorPrepareCompleted: + if sessionID != "" { + b.hub.BroadcastToSession(sessionID, msg) + return nil + } } b.hub.Broadcast(msg) return nil diff --git a/apps/backend/internal/integration/test_server_test.go b/apps/backend/internal/integration/test_server_test.go index 7000595bd9..8b71875135 100644 --- a/apps/backend/internal/integration/test_server_test.go +++ b/apps/backend/internal/integration/test_server_test.go @@ -270,7 +270,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { // 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) + orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, nil, nil, log) // Wire message creator for message persistence (similar to cmd/kandev/orchestrator.go) msgCreator := &testMessageCreatorAdapter{svc: taskSvc} diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 8b71dcb71a..0f42fec217 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -98,6 +99,9 @@ type LaunchAgentRequest struct { ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) + // Setup script from executor profile (runs in execution environment before agent starts) + SetupScript string + // Worktree configuration for concurrent agent execution UseWorktree bool // Whether to use a Git worktree for isolation RepositoryID string // Repository ID for worktree tracking @@ -164,6 +168,7 @@ type TaskStateChangeFunc func(ctx context.Context, taskID string, state v1.TaskS type Executor struct { agentManager AgentManagerClient repo repository.Repository + secretStore secrets.SecretStore shellPrefs ShellPreferenceProvider logger *logger.Logger @@ -183,7 +188,8 @@ type Executor struct { // ExecutorConfig holds configuration for the Executor type ExecutorConfig struct { - ShellPrefs ShellPreferenceProvider + ShellPrefs ShellPreferenceProvider + SecretStore secrets.SecretStore } type ShellPreferenceProvider interface { @@ -195,6 +201,7 @@ func NewExecutor(agentManager AgentManagerClient, repo repository.Repository, lo return &Executor{ agentManager: agentManager, repo: repo, + secretStore: cfg.SecretStore, shellPrefs: cfg.ShellPrefs, logger: log.WithFields(zap.String("component", "executor")), retryLimit: 3, diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 33fba618dc..6eebbbad6e 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -112,7 +112,7 @@ func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]st // 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, "") + return e.ExecuteWithFullProfile(ctx, task, "", "", "", task.Description, "") } // ExecuteWithProfile starts agent execution for a task using an explicit agent profile. @@ -121,8 +121,13 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, // 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) { + return e.ExecuteWithFullProfile(ctx, task, agentProfileID, executorID, "", prompt, workflowStepID) +} + +// ExecuteWithFullProfile starts agent execution for a task using an explicit agent profile and executor profile. +func (e *Executor) ExecuteWithFullProfile(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, executorProfileID string, prompt string, workflowStepID string) (*TaskExecution, error) { // Create session entry in database first - sessionID, err := e.PrepareSession(ctx, task, agentProfileID, executorID, workflowStepID) + sessionID, err := e.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) if err != nil { return nil, err } @@ -134,7 +139,7 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP // 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) { +func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, executorProfileID 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 @@ -181,6 +186,15 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi session.WorkflowStepID = &workflowStepID } + // Store executor profile ID on session + if executorProfileID != "" { + session.ExecutorProfileID = executorProfileID + if metadata == nil { + metadata = make(map[string]interface{}) + } + metadata["executor_profile_id"] = executorProfileID + } + // Resolve executor configuration execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) if execConfig.ExecutorID != "" { @@ -261,7 +275,7 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses return nil, err } - req, err := e.buildLaunchAgentRequest(ctx, task, sessionID, agentProfileID, executorID, prompt, repoInfo) + req, err := e.buildLaunchAgentRequest(ctx, task, session, agentProfileID, executorID, prompt, repoInfo) if err != nil { return nil, err } @@ -338,8 +352,15 @@ func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *m // 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) { +func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, executorID, prompt string, repoInfo *repoInfo) (*LaunchAgentRequest, error) { metadata := cloneMetadata(task.Metadata) + if session.ExecutorProfileID != "" { + if metadata == nil { + metadata = make(map[string]interface{}) + } + metadata["executor_profile_id"] = session.ExecutorProfileID + } + sessionID := session.ID req := &LaunchAgentRequest{ TaskID: task.ID, TaskTitle: task.Title, @@ -354,6 +375,16 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s metadata = execConfig.Metadata req.ExecutorType = execConfig.ExecutorType req.ExecutorConfig = execConfig.ExecutorCfg + req.SetupScript = execConfig.SetupScript + // Merge profile env vars into request env + if len(execConfig.ProfileEnv) > 0 { + if req.Env == nil { + req.Env = make(map[string]string) + } + for k, v := range execConfig.ProfileEnv { + req.Env[k] = v + } + } } if repoInfo.RepositoryPath != "" { diff --git a/apps/backend/internal/orchestrator/executor/executor_state.go b/apps/backend/internal/orchestrator/executor/executor_state.go index dc1e0bc702..6ca19d3b25 100644 --- a/apps/backend/internal/orchestrator/executor/executor_state.go +++ b/apps/backend/internal/orchestrator/executor/executor_state.go @@ -99,6 +99,8 @@ type executorConfig struct { ExecutorType string ExecutorCfg map[string]string // The executor record's Config map (docker_host, etc.) Metadata map[string]interface{} + SetupScript string // Setup script from profile + ProfileEnv map[string]string // Resolved env vars from profile (secrets decrypted) } // resolveExecutorConfig resolves executor configuration from an executor ID. @@ -129,16 +131,66 @@ func (e *Executor) resolveExecutorConfig(ctx context.Context, executorID, worksp } } - if policyJSON := strings.TrimSpace(executor.Config["mcp_policy"]); policyJSON != "" { - metadata["executor_mcp_policy"] = policyJSON - } - - return executorConfig{ + cfg := executorConfig{ ExecutorID: resolved, ExecutorType: string(executor.Type), ExecutorCfg: executor.Config, Metadata: metadata, } + + // Load profile by ID if specified in metadata, otherwise skip + profileID, _ := metadata["executor_profile_id"].(string) + if profileID != "" { + e.applyProfile(ctx, profileID, &cfg, metadata) + } + + return cfg +} + +// applyProfile loads an executor profile and applies its settings to the config. +func (e *Executor) applyProfile(ctx context.Context, profileID string, cfg *executorConfig, metadata map[string]interface{}) { + profile, err := e.repo.GetExecutorProfile(ctx, profileID) + if err != nil { + e.logger.Warn("failed to load executor profile", + zap.String("profile_id", profileID), + zap.Error(err)) + return + } + if profile == nil { + return + } + cfg.SetupScript = profile.PrepareScript + cfg.ProfileEnv = e.resolveProfileEnvVars(ctx, profile.EnvVars) + if policyJSON := strings.TrimSpace(profile.McpPolicy); policyJSON != "" { + metadata["executor_mcp_policy"] = policyJSON + } + if rulesJSON := profile.Config["sprites_network_policy_rules"]; rulesJSON != "" { + metadata["sprites_network_policy_rules"] = rulesJSON + } +} + +// resolveProfileEnvVars resolves profile env vars, dereferencing secret IDs to their values. +func (e *Executor) resolveProfileEnvVars(ctx context.Context, envVars []models.ProfileEnvVar) map[string]string { + if len(envVars) == 0 { + return nil + } + resolved := make(map[string]string, len(envVars)) + for _, ev := range envVars { + if ev.SecretID != "" && e.secretStore != nil { + value, err := e.secretStore.Reveal(ctx, ev.SecretID) + if err != nil { + e.logger.Warn("failed to resolve secret for profile env var", + zap.String("key", ev.Key), + zap.String("secret_id", ev.SecretID), + zap.Error(err)) + continue + } + resolved[ev.Key] = value + } else if ev.Value != "" { + resolved[ev.Key] = ev.Value + } + } + return resolved } func cloneMetadata(src map[string]interface{}) map[string]interface{} { diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index f417aac01b..08e818d26d 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -471,6 +471,24 @@ func (m *mockRepository) CountTasksByWorkflowStep(ctx context.Context, stepID st return 0, nil } +// Executor profile operations +func (m *mockRepository) CreateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error { + return nil +} +func (m *mockRepository) GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) { + return nil, nil +} +func (m *mockRepository) UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error { + return nil +} +func (m *mockRepository) DeleteExecutorProfile(ctx context.Context, id string) error { return nil } +func (m *mockRepository) ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) { + return nil, nil +} +func (m *mockRepository) ListAllExecutorProfiles(ctx context.Context) ([]*models.ExecutorProfile, error) { + return nil, nil +} + // Close operation func (m *mockRepository) Close() error { return nil } @@ -507,7 +525,7 @@ func TestPrepareSession_Success(t *testing.T) { Description: "Test description", } - sessionID, err := executor.PrepareSession(context.Background(), task, "profile-123", "executor-123", "step-123") + sessionID, err := executor.PrepareSession(context.Background(), task, "profile-123", "executor-123", "", "step-123") if err != nil { t.Fatalf("PrepareSession failed: %v", err) } @@ -551,7 +569,7 @@ func TestPrepareSession_NoAgentProfileID(t *testing.T) { WorkspaceID: "workspace-123", } - _, err := executor.PrepareSession(context.Background(), task, "", "executor-123", "step-123") + _, err := executor.PrepareSession(context.Background(), task, "", "executor-123", "", "step-123") if err != ErrNoAgentProfileID { t.Errorf("Expected ErrNoAgentProfileID, got %v", err) } @@ -578,7 +596,7 @@ func TestPrepareSession_WithRepository(t *testing.T) { WorkspaceID: "workspace-123", } - sessionID, err := executor.PrepareSession(context.Background(), task, "profile-123", "", "") + sessionID, err := executor.PrepareSession(context.Background(), task, "profile-123", "", "", "") if err != nil { t.Fatalf("PrepareSession failed: %v", err) } diff --git a/apps/backend/internal/orchestrator/executor/script_template.go b/apps/backend/internal/orchestrator/executor/script_template.go new file mode 100644 index 0000000000..b1a9260c60 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/script_template.go @@ -0,0 +1,10 @@ +package executor + +import "github.com/kandev/kandev/internal/scriptengine" + +// ResolveScript replaces {{key}} placeholders with values from the vars map. +// +// Deprecated: Use scriptengine.ResolveScript directly. +func ResolveScript(script string, vars map[string]string) string { + return scriptengine.ResolveScript(script, vars) +} diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index bb4ea44d65..ff0ecb8857 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -93,13 +93,14 @@ func (h *Handlers) wsTriggerTask(ctx context.Context, msg *ws.Message) (*ws.Mess } type wsStartTaskRequest struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - Priority int `json:"priority,omitempty"` - Prompt string `json:"prompt,omitempty"` - WorkflowStepID string `json:"workflow_step_id,omitempty"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` + Priority int `json:"priority,omitempty"` + Prompt string `json:"prompt,omitempty"` + WorkflowStepID string `json:"workflow_step_id,omitempty"` } func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -156,7 +157,7 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag } // Otherwise, create a new session - execution, err := h.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.Priority, req.Prompt, req.WorkflowStepID, false) + execution, err := h.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.ExecutorProfileID, req.Priority, req.Prompt, req.WorkflowStepID, false) if err != nil { h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start task: "+err.Error(), nil) diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 4cccf89233..1bac8d5b66 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -26,6 +26,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/queue" "github.com/kandev/kandev/internal/orchestrator/scheduler" "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -172,6 +173,7 @@ func NewService( taskRepo scheduler.TaskRepository, repo repository.Repository, shellPrefs executor.ShellPreferenceProvider, + secretStore secrets.SecretStore, log *logger.Logger, ) *Service { svcLogger := log.WithFields(zap.String("component", "orchestrator")) @@ -181,7 +183,8 @@ func NewService( // Create the executor with the agent manager client and repository for persistent sessions execCfg := executor.ExecutorConfig{ - ShellPrefs: shellPrefs, + ShellPrefs: shellPrefs, + SecretStore: secretStore, } exec := executor.NewExecutor(agentManager, repo, log, execCfg) diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 5201057c85..4e6e129915 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -50,11 +50,12 @@ func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error { // PrepareTaskSession creates a session entry without launching the agent. // This allows the HTTP handler to return the session ID immediately while the agent setup // continues in the background. Use StartTaskWithSession to continue with agent launch. -func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentProfileID string, executorID string, workflowStepID string) (string, error) { +func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, workflowStepID string) (string, error) { s.logger.Debug("preparing task session", zap.String("task_id", taskID), zap.String("agent_profile_id", agentProfileID), zap.String("executor_id", executorID), + zap.String("executor_profile_id", executorProfileID), zap.String("workflow_step_id", workflowStepID)) // Fetch the task to get workspace info @@ -67,7 +68,7 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr } // Create session entry in database - sessionID, err := s.executor.PrepareSession(ctx, task, agentProfileID, executorID, workflowStepID) + sessionID, err := s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) if err != nil { s.logger.Error("failed to prepare session", zap.String("task_id", taskID), @@ -96,7 +97,7 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr // This is used after PrepareTaskSession to continue with the agent launch. // If planMode is true and the workflow step doesn't already apply plan mode, // default plan mode instructions are injected into the prompt. -func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessionID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) { +func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessionID string, agentProfileID string, executorID string, executorProfileID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) { s.logger.Debug("starting task with existing session", zap.String("task_id", taskID), zap.String("session_id", sessionID), @@ -204,7 +205,7 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag // applied if the step has plan_mode enabled. // If planMode is true and the workflow step doesn't already apply plan mode, // default plan mode instructions are injected into the prompt. -func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) { +func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) { s.logger.Debug("manually starting task", zap.String("task_id", taskID), zap.String("agent_profile_id", agentProfileID), @@ -272,7 +273,7 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, workflowStepID, planMode) - execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, executorID, effectivePrompt, workflowStepID) + execution, err := s.executor.ExecuteWithFullProfile(ctx, task, agentProfileID, executorID, executorProfileID, effectivePrompt, workflowStepID) if err != nil { return nil, err } diff --git a/apps/backend/internal/scriptengine/defaults.go b/apps/backend/internal/scriptengine/defaults.go new file mode 100644 index 0000000000..0b80ba60eb --- /dev/null +++ b/apps/backend/internal/scriptengine/defaults.go @@ -0,0 +1,89 @@ +package scriptengine + +// DefaultPrepareScript returns the default prepare script for a given executor type string. +func DefaultPrepareScript(executorType string) string { + switch executorType { + case "local": + return defaultLocalPrepareScript + case "worktree": + return defaultWorktreePrepareScript + case "local_docker", "remote_docker": + return defaultDockerPrepareScript + case "sprites": + return defaultSpritesPrepareScript + default: + return "" + } +} + +const defaultLocalPrepareScript = `#!/bin/bash +# Prepare local environment +# This runs directly on your machine in the repository folder + +cd {{repository.path}} + +# Fetch latest changes from remote +git fetch --all --prune +` + +const defaultWorktreePrepareScript = `#!/bin/bash +# Prepare worktree environment +# A git worktree will be created from the base branch + +cd {{repository.path}} + +# Fetch latest changes before creating worktree +git fetch --all --prune +` + +const defaultDockerPrepareScript = `#!/bin/bash +# Prepare Docker container environment +# This runs inside the Docker container after it starts + +set -euo pipefail + +# ---- System dependencies ---- +apt-get update -qq +apt-get install -y -qq git curl ca-certificates > /dev/null 2>&1 + +# ---- Node.js (required for agentctl) ---- +if ! command -v node &> /dev/null; then + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 + apt-get install -y -qq nodejs > /dev/null 2>&1 +fi + +# ---- Clone repository ---- +git clone --depth=1 --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}} +cd {{workspace.path}} + +# ---- Repository setup (if configured) ---- +{{repository.setup_script}} + +# ---- Install and start Kandev agent controller ---- +{{kandev.agentctl.install}} +{{kandev.agentctl.start}} +` + +const defaultSpritesPrepareScript = `#!/bin/bash +# Prepare Sprites.dev cloud sandbox +# +# Pre-installed tools (no need to install): +# git, curl, wget, gh (GitHub CLI), node, python, go, +# build-essential, openssh-client, ca-certificates + +set -euo pipefail + +# ---- Clone repository ---- +echo "Cloning {{repository.clone_url}} (branch: {{repository.branch}})..." +git clone --depth=1 --quiet --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}} +cd {{workspace.path}} + +# ---- Repository setup (if configured) ---- +{{repository.setup_script}} + +# ---- Install and start Kandev agent controller ---- +echo "Starting agent controller..." +{{kandev.agentctl.install}} +{{kandev.agentctl.start}} +echo "Prepare complete." +` diff --git a/apps/backend/internal/scriptengine/placeholders.go b/apps/backend/internal/scriptengine/placeholders.go new file mode 100644 index 0000000000..7363de14c1 --- /dev/null +++ b/apps/backend/internal/scriptengine/placeholders.go @@ -0,0 +1,73 @@ +package scriptengine + +// PlaceholderInfo describes an available placeholder for documentation/autocomplete. +type PlaceholderInfo struct { + Key string `json:"key"` + Description string `json:"description"` + Example string `json:"example"` + ExecutorTypes []string `json:"executor_types"` +} + +// DefaultPlaceholders is the registry of all available script template placeholders. +var DefaultPlaceholders = []PlaceholderInfo{ + { + Key: "repository.path", + Description: "Local repository path on the host machine", + Example: "/Users/dev/myapp", + ExecutorTypes: []string{"local", "worktree"}, + }, + { + Key: "repository.name", + Description: "Repository name", + Example: "myapp", + ExecutorTypes: []string{"local", "worktree", "local_docker", "remote_docker", "sprites"}, + }, + { + Key: "repository.clone_url", + Description: "HTTPS clone URL (with auth token injected if available)", + Example: "https://token@github.com/org/myapp.git", + ExecutorTypes: []string{"local_docker", "remote_docker", "sprites"}, + }, + { + Key: "repository.ssh_url", + Description: "SSH clone URL", + Example: "git@github.com:org/myapp.git", + ExecutorTypes: []string{"local_docker", "remote_docker", "sprites"}, + }, + { + Key: "repository.branch", + Description: "Target branch name", + Example: "main", + ExecutorTypes: []string{"local", "worktree", "local_docker", "remote_docker", "sprites"}, + }, + { + Key: "repository.setup_script", + Description: "Repository-level setup script (if configured in repo settings)", + Example: "npm install", + ExecutorTypes: []string{"local_docker", "remote_docker", "sprites"}, + }, + { + Key: "workspace.path", + Description: "Working directory inside the executor environment", + Example: "/workspace", + ExecutorTypes: []string{"local_docker", "remote_docker", "sprites"}, + }, + { + Key: "kandev.agentctl.install", + Description: "Expands to full agentctl binary upload and install commands", + Example: "# (multi-line: upload binary, chmod, verify)", + ExecutorTypes: []string{"local_docker", "remote_docker", "sprites"}, + }, + { + Key: "kandev.agentctl.start", + Description: "Expands to agentctl start command with configured flags", + Example: "agentctl --port 8765 --workspace /workspace &", + ExecutorTypes: []string{"local_docker", "remote_docker", "sprites"}, + }, + { + Key: "kandev.agentctl.port", + Description: "Agentctl port number", + Example: "8765", + ExecutorTypes: []string{"local_docker", "remote_docker", "sprites"}, + }, +} diff --git a/apps/backend/internal/scriptengine/providers.go b/apps/backend/internal/scriptengine/providers.go new file mode 100644 index 0000000000..7878f9a067 --- /dev/null +++ b/apps/backend/internal/scriptengine/providers.go @@ -0,0 +1,106 @@ +package scriptengine + +import "fmt" + +// RepositoryProvider returns git-related placeholders from metadata and environment. +// Parameters: +// - metadata: executor create request metadata (contains "repository_path", "base_branch", etc.) +// - env: environment variables (contains "GITHUB_TOKEN", etc.) +// - repoURLResolver: resolves a local repo path to its remote URL (e.g., `git remote get-url origin`) +// - tokenInjector: injects auth token into a clone URL +func RepositoryProvider( + metadata map[string]any, + env map[string]string, + repoURLResolver func(string) (string, error), + tokenInjector func(string, map[string]string) string, +) PlaceholderProvider { + return func() map[string]string { + vars := make(map[string]string) + + repoPath := getMetaString(metadata, "repository_path") + if repoPath != "" { + vars["repository.path"] = repoPath + vars["repository.name"] = repoNameFromPath(repoPath) + } + + branch := getMetaString(metadata, "base_branch") + if branch == "" { + branch = getMetaString(metadata, "repository_branch") + } + vars["repository.branch"] = branch + + setupScript := getMetaString(metadata, "setup_script") + vars["repository.setup_script"] = setupScript + + // Clone URL: prefer explicit metadata, fall back to resolving from local repo + cloneURL := getMetaString(metadata, "repository_clone_url") + if cloneURL == "" && repoPath != "" && repoURLResolver != nil { + if remoteURL, err := repoURLResolver(repoPath); err == nil && remoteURL != "" { + vars["repository.ssh_url"] = remoteURL + cloneURL = remoteURL + } + } + if cloneURL != "" { + vars["repository.clone_url"] = injectToken(cloneURL, env, tokenInjector) + } + + return vars + } +} + +// AgentctlProvider returns kandev agentctl-related placeholders. +func AgentctlProvider(agentctlPort int, workspacePath string) PlaceholderProvider { + return func() map[string]string { + portStr := fmt.Sprintf("%d", agentctlPort) + return map[string]string{ + "kandev.agentctl.port": portStr, + "kandev.agentctl.install": "chmod +x /usr/local/bin/agentctl", + "kandev.agentctl.start": fmt.Sprintf( + "nohup agentctl --port %s --workdir %s > /tmp/agentctl.log 2>&1 &\nsleep 1", + portStr, workspacePath, + ), + } + } +} + +// WorkspaceProvider returns workspace path placeholder. +func WorkspaceProvider(workspacePath string) PlaceholderProvider { + return func() map[string]string { + return map[string]string{ + "workspace.path": workspacePath, + } + } +} + +// injectToken applies token injection to a URL if an injector is provided. +func injectToken(url string, env map[string]string, injector func(string, map[string]string) string) string { + if injector != nil { + return injector(url, env) + } + return url +} + +// getMetaString extracts a string value from a metadata map. +func getMetaString(metadata map[string]any, key string) string { + if metadata == nil { + return "" + } + if v, ok := metadata[key].(string); ok { + return v + } + return "" +} + +// repoNameFromPath extracts the repository name from a file path. +func repoNameFromPath(repoPath string) string { + if repoPath == "" { + return "" + } + // Find last path component + for i := len(repoPath) - 1; i >= 0; i-- { + if repoPath[i] == '/' { + return repoPath[i+1:] + } + } + return repoPath +} diff --git a/apps/backend/internal/scriptengine/resolver.go b/apps/backend/internal/scriptengine/resolver.go new file mode 100644 index 0000000000..8abdc854d8 --- /dev/null +++ b/apps/backend/internal/scriptengine/resolver.go @@ -0,0 +1,77 @@ +// Package scriptengine provides executor-agnostic script resolution with dynamic placeholders. +// It is designed to be reusable by any executor type (Sprites, Docker, remote SSH, etc.). +package scriptengine + +import ( + "maps" + "strings" +) + +// PlaceholderProvider supplies dynamic placeholder values at resolution time. +type PlaceholderProvider func() map[string]string + +// Resolver resolves {{key}} placeholders in scripts. +type Resolver struct { + staticVars map[string]string + providers []PlaceholderProvider +} + +// NewResolver creates a new placeholder resolver. +func NewResolver() *Resolver { + return &Resolver{ + staticVars: make(map[string]string), + } +} + +// WithStatic registers static key-value placeholders. +func (r *Resolver) WithStatic(vars map[string]string) *Resolver { + maps.Copy(r.staticVars, vars) + return r +} + +// WithProvider registers a dynamic placeholder provider. +func (r *Resolver) WithProvider(p PlaceholderProvider) *Resolver { + r.providers = append(r.providers, p) + return r +} + +// Resolve replaces all {{key}} placeholders in the script. +// Unknown placeholders are left as-is so the user can see what wasn't resolved. +func (r *Resolver) Resolve(script string) string { + if script == "" { + return script + } + vars := r.mergedVars() + if len(vars) == 0 { + return script + } + result := script + for key, value := range vars { + result = strings.ReplaceAll(result, "{{"+key+"}}", value) + } + return result +} + +// mergedVars collects all static vars and dynamic provider vars into a single map. +// Provider values override static values if keys collide. +func (r *Resolver) mergedVars() map[string]string { + merged := make(map[string]string, len(r.staticVars)) + maps.Copy(merged, r.staticVars) + for _, p := range r.providers { + maps.Copy(merged, p()) + } + return merged +} + +// ResolveScript is a convenience function that replaces {{key}} placeholders with values from vars. +// Unknown placeholders are left as-is. +func ResolveScript(script string, vars map[string]string) string { + if script == "" || len(vars) == 0 { + return script + } + result := script + for key, value := range vars { + result = strings.ReplaceAll(result, "{{"+key+"}}", value) + } + return result +} diff --git a/apps/backend/internal/secrets/crypto.go b/apps/backend/internal/secrets/crypto.go new file mode 100644 index 0000000000..d736f8a184 --- /dev/null +++ b/apps/backend/internal/secrets/crypto.go @@ -0,0 +1,107 @@ +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "io" + "os" + "path/filepath" +) + +const ( + // MasterKeyFile is the filename for the master encryption key. + MasterKeyFile = "master.key" + // MasterKeySize is the key size in bytes (AES-256). + MasterKeySize = 32 +) + +// MasterKeyProvider manages the master encryption key used for encrypting secrets at rest. +type MasterKeyProvider struct { + keyPath string + key []byte +} + +// NewMasterKeyProvider creates a provider that loads or generates the master key +// from the given kandev config directory. +func NewMasterKeyProvider(kandevDir string) (*MasterKeyProvider, error) { + keyPath := filepath.Join(kandevDir, MasterKeyFile) + provider := &MasterKeyProvider{keyPath: keyPath} + + if err := provider.loadOrGenerate(); err != nil { + return nil, fmt.Errorf("master key init: %w", err) + } + return provider, nil +} + +func (p *MasterKeyProvider) loadOrGenerate() error { + data, err := os.ReadFile(p.keyPath) + if err == nil && len(data) == MasterKeySize { + p.key = data + return nil + } + + key := make([]byte, MasterKeySize) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return fmt.Errorf("generate key: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(p.keyPath), 0700); err != nil { + return fmt.Errorf("create key dir: %w", err) + } + + if err := os.WriteFile(p.keyPath, key, 0600); err != nil { + return fmt.Errorf("write key: %w", err) + } + + p.key = key + return nil +} + +// Key returns the master key bytes. +func (p *MasterKeyProvider) Key() []byte { + return p.key +} + +// Encrypt encrypts plaintext using AES-256-GCM with a random nonce. +// Returns (ciphertext, nonce, error). +func Encrypt(plaintext, key []byte) ([]byte, []byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, fmt.Errorf("create GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, fmt.Errorf("generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + return ciphertext, nonce, nil +} + +// Decrypt decrypts ciphertext using AES-256-GCM. +func Decrypt(ciphertext, nonce, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create GCM: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decrypt: %w", err) + } + + return plaintext, nil +} diff --git a/apps/backend/internal/secrets/handlers.go b/apps/backend/internal/secrets/handlers.go new file mode 100644 index 0000000000..34ac6ece82 --- /dev/null +++ b/apps/backend/internal/secrets/handlers.go @@ -0,0 +1,190 @@ +package secrets + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// Handler provides HTTP and WebSocket handlers for secrets CRUD. +type Handler struct { + service *Service + logger *logger.Logger +} + +// NewHandler creates a new secrets handler. +func NewHandler(svc *Service, log *logger.Logger) *Handler { + return &Handler{service: svc, logger: log} +} + +// RegisterRoutes registers both HTTP and WS handlers. +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + h := NewHandler(svc, log) + h.registerHTTP(router) + h.registerWS(dispatcher) +} + +func (h *Handler) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.POST("/secrets", h.httpCreateSecret) + api.GET("/secrets", h.httpListSecrets) + api.GET("/secrets/:id", h.httpGetSecret) + api.PUT("/secrets/:id", h.httpUpdateSecret) + api.DELETE("/secrets/:id", h.httpDeleteSecret) + api.POST("/secrets/:id/reveal", h.httpRevealSecret) +} + +func (h *Handler) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionSecretList, h.wsList) + dispatcher.RegisterFunc(ws.ActionSecretCreate, h.wsCreate) + dispatcher.RegisterFunc(ws.ActionSecretUpdate, h.wsUpdate) + dispatcher.RegisterFunc(ws.ActionSecretDelete, h.wsDelete) + dispatcher.RegisterFunc(ws.ActionSecretReveal, h.wsReveal) +} + +// HTTP handlers + +func (h *Handler) httpCreateSecret(c *gin.Context) { + var req CreateSecretRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + item, err := h.service.Create(c.Request.Context(), &req) + if err != nil { + h.logger.Error("failed to create secret", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, item) +} + +func (h *Handler) httpListSecrets(c *gin.Context) { + items, err := h.service.List(c.Request.Context()) + if err != nil { + h.logger.Error("failed to list secrets", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list secrets"}) + return + } + c.JSON(http.StatusOK, items) +} + +func (h *Handler) httpGetSecret(c *gin.Context) { + id := c.Param("id") + secret, err := h.service.Get(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, secret) +} + +func (h *Handler) httpUpdateSecret(c *gin.Context) { + id := c.Param("id") + var req UpdateSecretRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + item, err := h.service.Update(c.Request.Context(), id, &req) + if err != nil { + h.logger.Error("failed to update secret", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, item) +} + +func (h *Handler) httpDeleteSecret(c *gin.Context) { + id := c.Param("id") + if err := h.service.Delete(c.Request.Context(), id); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +func (h *Handler) httpRevealSecret(c *gin.Context) { + id := c.Param("id") + value, err := h.service.Reveal(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, RevealSecretResponse{Value: value}) +} + +// WS handlers + +func (h *Handler) wsList(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + items, err := h.service.List(ctx) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, items) +} + +func (h *Handler) wsCreate(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req CreateSecretRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+err.Error(), nil) + } + + item, err := h.service.Create(ctx, &req) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, item) +} + +func (h *Handler) wsUpdate(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + ID string `json:"id"` + UpdateSecretRequest + } + if err := msg.ParsePayload(&payload); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+err.Error(), nil) + } + + item, err := h.service.Update(ctx, payload.ID, &payload.UpdateSecretRequest) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, item) +} + +func (h *Handler) wsDelete(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + ID string `json:"id"` + } + if err := msg.ParsePayload(&payload); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+err.Error(), nil) + } + + if err := h.service.Delete(ctx, payload.ID); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true}) +} + +func (h *Handler) wsReveal(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + ID string `json:"id"` + } + if err := msg.ParsePayload(&payload); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload: "+err.Error(), nil) + } + + value, err := h.service.Reveal(ctx, payload.ID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, RevealSecretResponse{Value: value}) +} diff --git a/apps/backend/internal/secrets/models.go b/apps/backend/internal/secrets/models.go new file mode 100644 index 0000000000..cb83271d70 --- /dev/null +++ b/apps/backend/internal/secrets/models.go @@ -0,0 +1,43 @@ +package secrets + +import "time" + +// Secret represents stored secret metadata (without the value). +type Secret struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SecretWithValue is used for create/update operations. +type SecretWithValue struct { + Secret + Value string `json:"value,omitempty"` +} + +// SecretListItem is returned by list endpoints — never contains the value. +type SecretListItem struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + HasValue bool `json:"has_value" db:"has_value"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CreateSecretRequest is the request body for creating a secret. +type CreateSecretRequest struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// UpdateSecretRequest is the request body for updating a secret. +type UpdateSecretRequest struct { + Name *string `json:"name,omitempty"` + Value *string `json:"value,omitempty"` +} + +// RevealSecretResponse is returned by the reveal endpoint. +type RevealSecretResponse struct { + Value string `json:"value"` +} diff --git a/apps/backend/internal/secrets/provider.go b/apps/backend/internal/secrets/provider.go new file mode 100644 index 0000000000..baaa404bbc --- /dev/null +++ b/apps/backend/internal/secrets/provider.go @@ -0,0 +1,51 @@ +package secrets + +import ( + "context" + + "github.com/kandev/kandev/internal/agent/credentials" +) + +// SecretStoreProvider bridges SecretStore into the credential provider chain. +// It implements credentials.CredentialProvider. +type SecretStoreProvider struct { + store SecretStore +} + +var _ credentials.CredentialProvider = (*SecretStoreProvider)(nil) + +// NewSecretStoreProvider creates a credential provider backed by the secret store. +func NewSecretStoreProvider(store SecretStore) *SecretStoreProvider { + return &SecretStoreProvider{store: store} +} + +// Name returns the provider name. +func (p *SecretStoreProvider) Name() string { + return "secret_store" +} + +// GetCredential retrieves a credential by secret ID from the encrypted store. +func (p *SecretStoreProvider) GetCredential(ctx context.Context, key string) (*credentials.Credential, error) { + value, err := p.store.Reveal(ctx, key) + if err != nil { + return nil, err + } + return &credentials.Credential{ + Key: key, + Value: value, + Source: "secret_store", + }, nil +} + +// ListAvailable returns all secret IDs that have stored values. +func (p *SecretStoreProvider) ListAvailable(ctx context.Context) ([]string, error) { + items, err := p.store.List(ctx) + if err != nil { + return nil, err + } + ids := make([]string, len(items)) + for i, item := range items { + ids[i] = item.ID + } + return ids, nil +} diff --git a/apps/backend/internal/secrets/service.go b/apps/backend/internal/secrets/service.go new file mode 100644 index 0000000000..c899951f8a --- /dev/null +++ b/apps/backend/internal/secrets/service.go @@ -0,0 +1,119 @@ +package secrets + +import ( + "context" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Service provides business logic and validation for secrets. +type Service struct { + store SecretStore + logger *logger.Logger +} + +// NewService creates a new secrets service. +func NewService(store SecretStore, log *logger.Logger) *Service { + return &Service{ + store: store, + logger: log, + } +} + +func (s *Service) validateCreate(req *CreateSecretRequest) error { + req.Name = strings.TrimSpace(req.Name) + + if req.Name == "" || len(req.Name) > 100 { + return fmt.Errorf("name must be 1-100 characters") + } + if req.Value == "" || len(req.Value) > 10000 { + return fmt.Errorf("value must be 1-10000 characters") + } + return nil +} + +func (s *Service) validateUpdate(req *UpdateSecretRequest) error { + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + req.Name = &name + if name == "" || len(name) > 100 { + return fmt.Errorf("name must be 1-100 characters") + } + } + if req.Value != nil && (len(*req.Value) == 0 || len(*req.Value) > 10000) { + return fmt.Errorf("value must be 1-10000 characters") + } + return nil +} + +// Create validates and stores a new secret. +func (s *Service) Create(ctx context.Context, req *CreateSecretRequest) (*SecretListItem, error) { + if err := s.validateCreate(req); err != nil { + return nil, fmt.Errorf("validation: %w", err) + } + + secret := &SecretWithValue{ + Secret: Secret{ + Name: req.Name, + }, + Value: req.Value, + } + + if err := s.store.Create(ctx, secret); err != nil { + return nil, fmt.Errorf("create secret: %w", err) + } + + return &SecretListItem{ + ID: secret.ID, + Name: secret.Name, + HasValue: true, + CreatedAt: secret.CreatedAt, + UpdatedAt: secret.UpdatedAt, + }, nil +} + +// Get retrieves secret metadata. +func (s *Service) Get(ctx context.Context, id string) (*Secret, error) { + return s.store.Get(ctx, id) +} + +// Reveal returns the decrypted secret value. +func (s *Service) Reveal(ctx context.Context, id string) (string, error) { + return s.store.Reveal(ctx, id) +} + +// Update validates and updates a secret. +func (s *Service) Update(ctx context.Context, id string, req *UpdateSecretRequest) (*SecretListItem, error) { + if err := s.validateUpdate(req); err != nil { + return nil, fmt.Errorf("validation: %w", err) + } + + if err := s.store.Update(ctx, id, req); err != nil { + return nil, fmt.Errorf("update secret: %w", err) + } + + secret, err := s.store.Get(ctx, id) + if err != nil { + return nil, err + } + + return &SecretListItem{ + ID: secret.ID, + Name: secret.Name, + HasValue: true, + CreatedAt: secret.CreatedAt, + UpdatedAt: secret.UpdatedAt, + }, nil +} + +// Delete removes a secret. +func (s *Service) Delete(ctx context.Context, id string) error { + return s.store.Delete(ctx, id) +} + +// List returns all secrets without values. +func (s *Service) List(ctx context.Context) ([]*SecretListItem, error) { + return s.store.List(ctx) +} diff --git a/apps/backend/internal/secrets/sqlite_store.go b/apps/backend/internal/secrets/sqlite_store.go new file mode 100644 index 0000000000..631a7fd7cd --- /dev/null +++ b/apps/backend/internal/secrets/sqlite_store.go @@ -0,0 +1,209 @@ +package secrets + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type sqliteStore struct { + db *sqlx.DB // writer + ro *sqlx.DB // reader + crypto *MasterKeyProvider + ownsDB bool +} + +var _ SecretStore = (*sqliteStore)(nil) + +// Provide creates the SQLite secret store using separate writer and reader pools. +func Provide(writer, reader *sqlx.DB, crypto *MasterKeyProvider) (*sqliteStore, func() error, error) { + store := &sqliteStore{db: writer, ro: reader, crypto: crypto} + if err := store.initSchema(); err != nil { + return nil, nil, fmt.Errorf("secrets schema init: %w", err) + } + return store, store.Close, nil +} + +func (s *sqliteStore) initSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS secrets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + encrypted_value BLOB NOT NULL, + nonce BLOB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + ` + _, err := s.db.Exec(schema) + return err +} + +func (s *sqliteStore) Close() error { + if !s.ownsDB { + return nil + } + return s.db.Close() +} + +func (s *sqliteStore) Create(ctx context.Context, secret *SecretWithValue) error { + if secret.ID == "" { + secret.ID = uuid.New().String() + } + now := time.Now().UTC() + secret.CreatedAt = now + secret.UpdatedAt = now + + ciphertext, nonce, err := Encrypt([]byte(secret.Value), s.crypto.Key()) + if err != nil { + return fmt.Errorf("encrypt secret: %w", err) + } + + _, err = s.db.ExecContext(ctx, ` + INSERT INTO secrets (id, name, encrypted_value, nonce, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + secret.ID, secret.Name, ciphertext, nonce, now, now, + ) + if err != nil { + return fmt.Errorf("insert secret: %w", err) + } + return nil +} + +func (s *sqliteStore) Get(ctx context.Context, id string) (*Secret, error) { + var row secretRow + err := s.ro.GetContext(ctx, &row, ` + SELECT id, name, created_at, updated_at + FROM secrets WHERE id = ?`, id) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("secret not found: %s", id) + } + return nil, fmt.Errorf("get secret: %w", err) + } + return row.toSecret(), nil +} + +func (s *sqliteStore) Reveal(ctx context.Context, id string) (string, error) { + var ciphertext, nonce []byte + err := s.ro.QueryRowContext(ctx, ` + SELECT encrypted_value, nonce FROM secrets WHERE id = ?`, id). + Scan(&ciphertext, &nonce) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("secret not found: %s", id) + } + return "", fmt.Errorf("reveal secret: %w", err) + } + + plaintext, err := Decrypt(ciphertext, nonce, s.crypto.Key()) + if err != nil { + return "", fmt.Errorf("decrypt secret: %w", err) + } + return string(plaintext), nil +} + +func (s *sqliteStore) Update(ctx context.Context, id string, req *UpdateSecretRequest) error { + existing, err := s.Get(ctx, id) + if err != nil { + return err + } + + now := time.Now().UTC() + + if req.Name != nil { + existing.Name = *req.Name + } + + if req.Value != nil { + ciphertext, nonce, err := Encrypt([]byte(*req.Value), s.crypto.Key()) + if err != nil { + return fmt.Errorf("encrypt secret: %w", err) + } + _, err = s.db.ExecContext(ctx, ` + UPDATE secrets SET name = ?, encrypted_value = ?, nonce = ?, updated_at = ? + WHERE id = ?`, + existing.Name, ciphertext, nonce, now, id, + ) + if err != nil { + return fmt.Errorf("update secret: %w", err) + } + } else { + _, err = s.db.ExecContext(ctx, ` + UPDATE secrets SET name = ?, updated_at = ? + WHERE id = ?`, + existing.Name, now, id, + ) + if err != nil { + return fmt.Errorf("update secret: %w", err) + } + } + return nil +} + +func (s *sqliteStore) Delete(ctx context.Context, id string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM secrets WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete secret: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("secret not found: %s", id) + } + return nil +} + +func (s *sqliteStore) List(ctx context.Context) ([]*SecretListItem, error) { + var rows []secretListRow + err := s.ro.SelectContext(ctx, &rows, ` + SELECT id, name, 1 as has_value, created_at, updated_at + FROM secrets ORDER BY created_at DESC`) + if err != nil { + return nil, fmt.Errorf("list secrets: %w", err) + } + return toSecretListItems(rows), nil +} + +// secretRow is the DB scan target for secret metadata queries. +type secretRow struct { + ID string `db:"id"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (r *secretRow) toSecret() *Secret { + return &Secret{ + ID: r.ID, + Name: r.Name, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +// secretListRow is the DB scan target for list queries. +type secretListRow struct { + ID string `db:"id"` + Name string `db:"name"` + HasValue bool `db:"has_value"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func toSecretListItems(rows []secretListRow) []*SecretListItem { + items := make([]*SecretListItem, len(rows)) + for i, r := range rows { + items[i] = &SecretListItem{ + ID: r.ID, + Name: r.Name, + HasValue: r.HasValue, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + } + return items +} diff --git a/apps/backend/internal/secrets/store.go b/apps/backend/internal/secrets/store.go new file mode 100644 index 0000000000..f99404e9bf --- /dev/null +++ b/apps/backend/internal/secrets/store.go @@ -0,0 +1,28 @@ +package secrets + +import "context" + +// SecretStore abstracts secret storage. Implementations handle +// encryption/decryption internally. +type SecretStore interface { + // Create stores a new secret (encrypts the value). + Create(ctx context.Context, secret *SecretWithValue) error + + // Get retrieves secret metadata (without value). + Get(ctx context.Context, id string) (*Secret, error) + + // Reveal retrieves the decrypted value of a secret. + Reveal(ctx context.Context, id string) (string, error) + + // Update updates a secret's name and/or value. + Update(ctx context.Context, id string, req *UpdateSecretRequest) error + + // Delete permanently removes a secret. + Delete(ctx context.Context, id string) error + + // List returns all secrets without values. + List(ctx context.Context) ([]*SecretListItem, error) + + // Close releases resources. + Close() error +} diff --git a/apps/backend/internal/sprites/handlers.go b/apps/backend/internal/sprites/handlers.go new file mode 100644 index 0000000000..e4f71f0bd3 --- /dev/null +++ b/apps/backend/internal/sprites/handlers.go @@ -0,0 +1,477 @@ +package sprites + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + sprites "github.com/superfly/sprites-go" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" + ws "github.com/kandev/kandev/pkg/websocket" +) + +const ( + spritesPrefix = "kandev-" + requestTimeout = 30 * time.Second + testStepTimeout = 60 * time.Second +) + +// Handler provides HTTP and WebSocket handlers for Sprites management. +type Handler struct { + secretStore secrets.SecretStore + logger *logger.Logger +} + +// NewHandler creates a new sprites handler. +func NewHandler(secretStore secrets.SecretStore, log *logger.Logger) *Handler { + return &Handler{ + secretStore: secretStore, + logger: log.WithFields(zap.String("component", "sprites-handler")), + } +} + +// RegisterRoutes registers both HTTP and WS handlers. +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, secretStore secrets.SecretStore, log *logger.Logger) { + h := NewHandler(secretStore, log) + h.registerHTTP(router) + h.registerWS(dispatcher) +} + +func (h *Handler) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1/sprites") + api.GET("/status", h.httpStatus) + api.GET("/instances", h.httpListInstances) + api.DELETE("/instances/:name", h.httpDestroyInstance) + api.DELETE("/instances", h.httpDestroyAll) + api.POST("/test", h.httpTest) + api.GET("/network-policies", h.httpGetNetworkPolicy) + api.PUT("/network-policies", h.httpUpdateNetworkPolicy) +} + +func (h *Handler) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionSpritesStatus, h.wsStatus) + dispatcher.RegisterFunc(ws.ActionSpritesInstancesList, h.wsListInstances) + dispatcher.RegisterFunc(ws.ActionSpritesInstancesDestroy, h.wsDestroyInstance) + dispatcher.RegisterFunc(ws.ActionSpritesTest, h.wsTest) + dispatcher.RegisterFunc(ws.ActionSpritesNetworkPolicyGet, h.wsGetNetworkPolicy) + dispatcher.RegisterFunc(ws.ActionSpritesNetworkPolicyUpdate, h.wsUpdateNetworkPolicy) +} + +// --- Response types --- + +// SpritesStatus is the status response. +type SpritesStatus struct { + Connected bool `json:"connected"` + TokenConfigured bool `json:"token_configured"` + InstanceCount int `json:"instance_count"` + Error string `json:"error,omitempty"` +} + +// SpritesInstance represents a running sprite. +type SpritesInstance struct { + Name string `json:"name"` + HealthStatus string `json:"health_status"` + CreatedAt string `json:"created_at"` + UptimeSeconds int64 `json:"uptime_seconds"` +} + +// SpritesTestResult is the test connection result. +type SpritesTestResult struct { + Success bool `json:"success"` + Steps []SpritesTestStep `json:"steps"` + TotalDurationMs int64 `json:"total_duration_ms"` + SpriteName string `json:"sprite_name"` + Error string `json:"error,omitempty"` +} + +// SpritesTestStep is a single step in the test. +type SpritesTestStep struct { + Name string `json:"name"` + DurationMs int64 `json:"duration_ms"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// --- HTTP handlers --- + +func (h *Handler) httpStatus(c *gin.Context) { + secretID := c.Query("secret_id") + status := h.getStatus(c.Request.Context(), secretID) + c.JSON(http.StatusOK, status) +} + +func (h *Handler) httpListInstances(c *gin.Context) { + secretID := c.Query("secret_id") + instances, err := h.listInstances(c.Request.Context(), secretID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, instances) +} + +func (h *Handler) httpDestroyInstance(c *gin.Context) { + secretID := c.Query("secret_id") + name := c.Param("name") + if err := h.destroyInstance(c.Request.Context(), secretID, name); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func (h *Handler) httpDestroyAll(c *gin.Context) { + secretID := c.Query("secret_id") + count, err := h.destroyAll(c.Request.Context(), secretID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "destroyed": count}) +} + +func (h *Handler) httpTest(c *gin.Context) { + secretID := c.Query("secret_id") + result := h.testConnection(c.Request.Context(), secretID) + c.JSON(http.StatusOK, result) +} + +func (h *Handler) httpGetNetworkPolicy(c *gin.Context) { + secretID := c.Query("secret_id") + spriteName := c.Query("sprite_name") + if spriteName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sprite_name is required"}) + return + } + policy, err := h.getNetworkPolicy(c.Request.Context(), secretID, spriteName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, policy) +} + +func (h *Handler) httpUpdateNetworkPolicy(c *gin.Context) { + secretID := c.Query("secret_id") + spriteName := c.Query("sprite_name") + if spriteName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sprite_name is required"}) + return + } + + var policy sprites.NetworkPolicy + if err := c.ShouldBindJSON(&policy); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()}) + return + } + + if err := h.updateNetworkPolicy(c.Request.Context(), secretID, spriteName, &policy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// --- WS handlers --- + +func (h *Handler) wsStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + SecretID string `json:"secret_id"` + } + _ = msg.ParsePayload(&payload) + return ws.NewResponse(msg.ID, msg.Action, h.getStatus(ctx, payload.SecretID)) +} + +func (h *Handler) wsListInstances(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + SecretID string `json:"secret_id"` + } + _ = msg.ParsePayload(&payload) + instances, err := h.listInstances(ctx, payload.SecretID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, instances) +} + +func (h *Handler) wsDestroyInstance(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + SecretID string `json:"secret_id"` + Name string `json:"name"` + } + if err := msg.ParsePayload(&payload); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if err := h.destroyInstance(ctx, payload.SecretID, payload.Name); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true}) +} + +func (h *Handler) wsTest(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + SecretID string `json:"secret_id"` + } + _ = msg.ParsePayload(&payload) + return ws.NewResponse(msg.ID, msg.Action, h.testConnection(ctx, payload.SecretID)) +} + +func (h *Handler) wsGetNetworkPolicy(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + SecretID string `json:"secret_id"` + SpriteName string `json:"sprite_name"` + } + if err := msg.ParsePayload(&payload); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if payload.SpriteName == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "sprite_name is required", nil) + } + policy, err := h.getNetworkPolicy(ctx, payload.SecretID, payload.SpriteName) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, policy) +} + +func (h *Handler) wsUpdateNetworkPolicy(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + SecretID string `json:"secret_id"` + SpriteName string `json:"sprite_name"` + Policy sprites.NetworkPolicy `json:"policy"` + } + if err := msg.ParsePayload(&payload); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if payload.SpriteName == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "sprite_name is required", nil) + } + if err := h.updateNetworkPolicy(ctx, payload.SecretID, payload.SpriteName, &payload.Policy); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true}) +} + +// --- Business logic --- + +func (h *Handler) getToken(ctx context.Context, secretID string) (string, error) { + if h.secretStore == nil { + return "", fmt.Errorf("secret store not available") + } + if secretID == "" { + return "", fmt.Errorf("secret_id is required") + } + return h.secretStore.Reveal(ctx, secretID) +} + +func (h *Handler) getStatus(ctx context.Context, secretID string) *SpritesStatus { + token, err := h.getToken(ctx, secretID) + if err != nil { + return &SpritesStatus{TokenConfigured: false} + } + if token == "" { + return &SpritesStatus{TokenConfigured: false} + } + + instances, err := h.listInstances(ctx, secretID) + if err != nil { + return &SpritesStatus{ + TokenConfigured: true, + Connected: false, + Error: err.Error(), + } + } + + return &SpritesStatus{ + TokenConfigured: true, + Connected: true, + InstanceCount: len(instances), + } +} + +func (h *Handler) listInstances(ctx context.Context, secretID string) ([]*SpritesInstance, error) { + client, err := h.createClient(ctx, secretID) + if err != nil { + return nil, err + } + defer func() { _ = client.Close() }() + + reqCtx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + list, err := client.ListSprites(reqCtx, nil) + if err != nil { + return nil, fmt.Errorf("failed to list sprites: %w", err) + } + + var result []*SpritesInstance + for _, s := range list.Sprites { + if !strings.HasPrefix(s.Name, spritesPrefix) { + continue + } + createdAt := s.CreatedAt.Format(time.RFC3339) + result = append(result, &SpritesInstance{ + Name: s.Name, + HealthStatus: s.Status, + CreatedAt: createdAt, + UptimeSeconds: computeUptime(createdAt), + }) + } + return result, nil +} + +func (h *Handler) destroyInstance(ctx context.Context, secretID, name string) error { + token, err := h.getToken(ctx, secretID) + if err != nil { + return fmt.Errorf("API token not configured: %w", err) + } + + client := sprites.New(token) + sprite := client.Sprite(name) + if err := sprite.Destroy(); err != nil { + return fmt.Errorf("failed to destroy sprite %q: %w", name, err) + } + h.logger.Info("destroyed sprite", zap.String("name", name)) + return nil +} + +func (h *Handler) destroyAll(ctx context.Context, secretID string) (int, error) { + instances, err := h.listInstances(ctx, secretID) + if err != nil { + return 0, err + } + + token, err := h.getToken(ctx, secretID) + if err != nil { + return 0, err + } + + destroyed := 0 + client := sprites.New(token) + for _, inst := range instances { + sprite := client.Sprite(inst.Name) + if err := sprite.Destroy(); err != nil { + h.logger.Warn("failed to destroy sprite", zap.String("name", inst.Name), zap.Error(err)) + continue + } + destroyed++ + } + h.logger.Info("destroyed all kandev sprites", zap.Int("count", destroyed)) + return destroyed, nil +} + +func (h *Handler) testConnection(ctx context.Context, secretID string) *SpritesTestResult { + start := time.Now() + result := &SpritesTestResult{} + + // Step 1: Get token + tokenStep := h.runTestStep("Get API token", func() error { + _, err := h.getToken(ctx, secretID) + return err + }) + result.Steps = append(result.Steps, tokenStep) + if !tokenStep.Success { + result.Error = tokenStep.Error + result.TotalDurationMs = time.Since(start).Milliseconds() + return result + } + + // Step 2: List sprites (verifies token + API connectivity) + var instanceCount int + listStep := h.runTestStep("List sprites", func() error { + instances, err := h.listInstances(ctx, secretID) + if err != nil { + return err + } + instanceCount = len(instances) + return nil + }) + result.Steps = append(result.Steps, listStep) + if listStep.Success { + result.SpriteName = fmt.Sprintf("%d active sprite(s)", instanceCount) + } + + result.Success = tokenStep.Success && listStep.Success + if !result.Success { + for _, s := range result.Steps { + if s.Error != "" { + result.Error = s.Error + break + } + } + } + result.TotalDurationMs = time.Since(start).Milliseconds() + return result +} + +func (h *Handler) runTestStep(name string, fn func() error) SpritesTestStep { + start := time.Now() + err := fn() + step := SpritesTestStep{ + Name: name, + DurationMs: time.Since(start).Milliseconds(), + Success: err == nil, + } + if err != nil { + step.Error = err.Error() + } + return step +} + +func (h *Handler) createClient(ctx context.Context, secretID string) (*sprites.Client, error) { + token, err := h.getToken(ctx, secretID) + if err != nil { + return nil, fmt.Errorf("API token not configured: %w", err) + } + return sprites.New(token, sprites.WithDisableControl()), nil +} + +func (h *Handler) getNetworkPolicy(ctx context.Context, secretID, spriteName string) (*sprites.NetworkPolicy, error) { + client, err := h.createClient(ctx, secretID) + if err != nil { + return nil, err + } + defer func() { _ = client.Close() }() + + reqCtx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + policy, err := client.GetNetworkPolicy(reqCtx, spriteName) + if err != nil { + return nil, fmt.Errorf("failed to get network policy for %q: %w", spriteName, err) + } + return policy, nil +} + +func (h *Handler) updateNetworkPolicy(ctx context.Context, secretID, spriteName string, policy *sprites.NetworkPolicy) error { + client, err := h.createClient(ctx, secretID) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + + reqCtx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + if err := client.UpdateNetworkPolicy(reqCtx, spriteName, policy); err != nil { + return fmt.Errorf("failed to update network policy for %q: %w", spriteName, err) + } + h.logger.Info("updated network policy", + zap.String("sprite_name", spriteName), + zap.Int("rule_count", len(policy.Rules))) + return nil +} + +func computeUptime(createdAt string) int64 { + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return 0 + } + return int64(time.Since(t).Seconds()) +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 8c6c4a3d4b..208da33324 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -68,10 +68,31 @@ type ExecutorDTO struct { IsSystem bool `json:"is_system"` Resumable bool `json:"resumable"` Config map[string]string `json:"config,omitempty"` + Profiles []ExecutorProfileDTO `json:"profiles,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +type ExecutorProfileDTO struct { + ID string `json:"id"` + ExecutorID string `json:"executor_id"` + ExecutorType string `json:"executor_type,omitempty"` + ExecutorName string `json:"executor_name,omitempty"` + Name string `json:"name"` + McpPolicy string `json:"mcp_policy,omitempty"` + Config map[string]string `json:"config,omitempty"` + PrepareScript string `json:"prepare_script"` + CleanupScript string `json:"cleanup_script"` + EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListExecutorProfilesResponse struct { + Profiles []ExecutorProfileDTO `json:"profiles"` + Total int `json:"total"` +} + type EnvironmentDTO struct { ID string `json:"id"` Name string `json:"name"` @@ -338,6 +359,32 @@ func FromExecutor(executor *models.Executor) ExecutorDTO { } } +func FromExecutorProfile(profile *models.ExecutorProfile) ExecutorProfileDTO { + return ExecutorProfileDTO{ + ID: profile.ID, + ExecutorID: profile.ExecutorID, + Name: profile.Name, + McpPolicy: profile.McpPolicy, + Config: profile.Config, + PrepareScript: profile.PrepareScript, + CleanupScript: profile.CleanupScript, + EnvVars: profile.EnvVars, + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, + } +} + +// FromExecutorProfileWithExecutor converts an ExecutorProfile model to a DTO +// with executor type and name populated. +func FromExecutorProfileWithExecutor(profile *models.ExecutorProfile, executor *models.Executor) ExecutorProfileDTO { + d := FromExecutorProfile(profile) + if executor != nil { + d.ExecutorType = string(executor.Type) + d.ExecutorName = executor.Name + } + return d +} + func FromEnvironment(environment *models.Environment) EnvironmentDTO { return EnvironmentDTO{ ID: environment.ID, diff --git a/apps/backend/internal/task/handlers/executor_handlers.go b/apps/backend/internal/task/handlers/executor_handlers.go index 6f8763c1c3..7703eeb9f5 100644 --- a/apps/backend/internal/task/handlers/executor_handlers.go +++ b/apps/backend/internal/task/handlers/executor_handlers.go @@ -52,12 +52,19 @@ func (h *ExecutorHandlers) listExecutors(ctx context.Context) (dto.ListExecutors if err != nil { return dto.ListExecutorsResponse{}, err } + profiles, _ := h.service.ListAllExecutorProfiles(ctx) + profilesByExecutor := make(map[string][]dto.ExecutorProfileDTO, len(executors)) + for _, p := range profiles { + profilesByExecutor[p.ExecutorID] = append(profilesByExecutor[p.ExecutorID], dto.FromExecutorProfile(p)) + } resp := dto.ListExecutorsResponse{ Executors: make([]dto.ExecutorDTO, 0, len(executors)), Total: len(executors), } for _, executor := range executors { - resp.Executors = append(resp.Executors, dto.FromExecutor(executor)) + d := dto.FromExecutor(executor) + d.Profiles = profilesByExecutor[executor.ID] + resp.Executors = append(resp.Executors, d) } return resp, nil } diff --git a/apps/backend/internal/task/handlers/executor_profile_handlers.go b/apps/backend/internal/task/handlers/executor_profile_handlers.go new file mode 100644 index 0000000000..138071fb0f --- /dev/null +++ b/apps/backend/internal/task/handlers/executor_profile_handlers.go @@ -0,0 +1,354 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/scriptengine" + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" +) + +type ExecutorProfileHandlers struct { + service *service.Service + logger *logger.Logger +} + +func NewExecutorProfileHandlers(svc *service.Service, log *logger.Logger) *ExecutorProfileHandlers { + return &ExecutorProfileHandlers{service: svc, logger: log} +} + +func RegisterExecutorProfileRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *service.Service, log *logger.Logger) { + handlers := NewExecutorProfileHandlers(svc, log) + handlers.registerHTTP(router) + handlers.registerWS(dispatcher) +} + +func (h *ExecutorProfileHandlers) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.GET("/executor-profiles", h.httpListAllProfiles) + api.GET("/executor-profiles/default-script", h.httpGetDefaultScript) + api.GET("/script-placeholders", h.httpListScriptPlaceholders) + api.GET("/executors/:id/profiles", h.httpListProfiles) + api.POST("/executors/:id/profiles", h.httpCreateProfile) + api.GET("/executors/:id/profiles/:profileId", h.httpGetProfile) + api.PATCH("/executors/:id/profiles/:profileId", h.httpUpdateProfile) + api.DELETE("/executors/:id/profiles/:profileId", h.httpDeleteProfile) +} + +func (h *ExecutorProfileHandlers) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionExecutorProfileList, h.wsListProfiles) + dispatcher.RegisterFunc(ws.ActionExecutorProfileListAll, h.wsListAllProfiles) + dispatcher.RegisterFunc(ws.ActionExecutorProfileCreate, h.wsCreateProfile) + dispatcher.RegisterFunc(ws.ActionExecutorProfileGet, h.wsGetProfile) + dispatcher.RegisterFunc(ws.ActionExecutorProfileUpdate, h.wsUpdateProfile) + dispatcher.RegisterFunc(ws.ActionExecutorProfileDelete, h.wsDeleteProfile) +} + +func (h *ExecutorProfileHandlers) httpListAllProfiles(c *gin.Context) { + ctx := c.Request.Context() + profiles, err := h.service.ListAllExecutorProfiles(ctx) + if err != nil { + h.logger.Error("failed to list all executor profiles", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list profiles"}) + return + } + executors, err := h.service.ListExecutors(ctx) + if err != nil { + h.logger.Error("failed to list executors for profile enrichment", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list executors"}) + return + } + executorMap := make(map[string]*models.Executor, len(executors)) + for _, ex := range executors { + executorMap[ex.ID] = ex + } + resp := dto.ListExecutorProfilesResponse{ + Profiles: make([]dto.ExecutorProfileDTO, 0, len(profiles)), + Total: len(profiles), + } + for _, p := range profiles { + resp.Profiles = append(resp.Profiles, dto.FromExecutorProfileWithExecutor(p, executorMap[p.ExecutorID])) + } + c.JSON(http.StatusOK, resp) +} + +func (h *ExecutorProfileHandlers) httpListScriptPlaceholders(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"placeholders": scriptPlaceholders}) +} + +func (h *ExecutorProfileHandlers) httpGetDefaultScript(c *gin.Context) { + executorType := c.Query("type") + if executorType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "type query parameter is required"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "prepare_script": scriptengine.DefaultPrepareScript(executorType), + "cleanup_script": "", + }) +} + +func (h *ExecutorProfileHandlers) httpListProfiles(c *gin.Context) { + executorID := c.Param("id") + profiles, err := h.service.ListExecutorProfiles(c.Request.Context(), executorID) + if err != nil { + h.logger.Error("failed to list executor profiles", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list profiles"}) + return + } + resp := dto.ListExecutorProfilesResponse{ + Profiles: make([]dto.ExecutorProfileDTO, 0, len(profiles)), + Total: len(profiles), + } + for _, p := range profiles { + resp.Profiles = append(resp.Profiles, dto.FromExecutorProfile(p)) + } + c.JSON(http.StatusOK, resp) +} + +type httpCreateProfileRequest struct { + Name string `json:"name"` + McpPolicy string `json:"mcp_policy"` + Config map[string]string `json:"config,omitempty"` + PrepareScript string `json:"prepare_script"` + CleanupScript string `json:"cleanup_script"` + EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"` +} + +func (h *ExecutorProfileHandlers) httpCreateProfile(c *gin.Context) { + var body httpCreateProfileRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + profile, err := h.service.CreateExecutorProfile(c.Request.Context(), &service.CreateExecutorProfileRequest{ + ExecutorID: c.Param("id"), + Name: body.Name, + McpPolicy: body.McpPolicy, + Config: body.Config, + PrepareScript: body.PrepareScript, + CleanupScript: body.CleanupScript, + EnvVars: body.EnvVars, + }) + if err != nil { + h.logger.Error("failed to create executor profile", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "profile not created"}) + return + } + c.JSON(http.StatusOK, dto.FromExecutorProfile(profile)) +} + +func (h *ExecutorProfileHandlers) httpGetProfile(c *gin.Context) { + profile, err := h.service.GetExecutorProfile(c.Request.Context(), c.Param("profileId")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) + return + } + c.JSON(http.StatusOK, dto.FromExecutorProfile(profile)) +} + +type httpUpdateProfileRequest struct { + Name *string `json:"name,omitempty"` + McpPolicy *string `json:"mcp_policy,omitempty"` + Config map[string]string `json:"config,omitempty"` + PrepareScript *string `json:"prepare_script,omitempty"` + CleanupScript *string `json:"cleanup_script,omitempty"` + EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"` +} + +func (h *ExecutorProfileHandlers) httpUpdateProfile(c *gin.Context) { + var body httpUpdateProfileRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + profile, err := h.service.UpdateExecutorProfile(c.Request.Context(), c.Param("profileId"), &service.UpdateExecutorProfileRequest{ + Name: body.Name, + McpPolicy: body.McpPolicy, + Config: body.Config, + PrepareScript: body.PrepareScript, + CleanupScript: body.CleanupScript, + EnvVars: body.EnvVars, + }) + if err != nil { + h.logger.Error("failed to update executor profile", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "profile not updated"}) + return + } + c.JSON(http.StatusOK, dto.FromExecutorProfile(profile)) +} + +func (h *ExecutorProfileHandlers) httpDeleteProfile(c *gin.Context) { + if err := h.service.DeleteExecutorProfile(c.Request.Context(), c.Param("profileId")); err != nil { + h.logger.Error("failed to delete executor profile", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": "profile not deleted"}) + return + } + c.JSON(http.StatusOK, dto.SuccessResponse{Success: true}) +} + +// WebSocket handlers + +type wsListProfilesRequest struct { + ExecutorID string `json:"executor_id"` +} + +func (h *ExecutorProfileHandlers) wsListProfiles(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsListProfilesRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ExecutorID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "executor_id is required", nil) + } + profiles, err := h.service.ListExecutorProfiles(ctx, req.ExecutorID) + if err != nil { + h.logger.Error("failed to list executor profiles", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list profiles", nil) + } + resp := dto.ListExecutorProfilesResponse{ + Profiles: make([]dto.ExecutorProfileDTO, 0, len(profiles)), + Total: len(profiles), + } + for _, p := range profiles { + resp.Profiles = append(resp.Profiles, dto.FromExecutorProfile(p)) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +func (h *ExecutorProfileHandlers) wsListAllProfiles(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + profiles, err := h.service.ListAllExecutorProfiles(ctx) + if err != nil { + h.logger.Error("failed to list all executor profiles", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list profiles", nil) + } + executors, err := h.service.ListExecutors(ctx) + if err != nil { + h.logger.Error("failed to list executors for profile enrichment", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list executors", nil) + } + executorMap := make(map[string]*models.Executor, len(executors)) + for _, ex := range executors { + executorMap[ex.ID] = ex + } + resp := dto.ListExecutorProfilesResponse{ + Profiles: make([]dto.ExecutorProfileDTO, 0, len(profiles)), + Total: len(profiles), + } + for _, p := range profiles { + resp.Profiles = append(resp.Profiles, dto.FromExecutorProfileWithExecutor(p, executorMap[p.ExecutorID])) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsCreateProfileRequest struct { + ExecutorID string `json:"executor_id"` + Name string `json:"name"` + McpPolicy string `json:"mcp_policy"` + Config map[string]string `json:"config,omitempty"` + PrepareScript string `json:"prepare_script"` + CleanupScript string `json:"cleanup_script"` + EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"` +} + +func (h *ExecutorProfileHandlers) wsCreateProfile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsCreateProfileRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ExecutorID == "" || req.Name == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "executor_id and name are required", nil) + } + profile, err := h.service.CreateExecutorProfile(ctx, &service.CreateExecutorProfileRequest{ + ExecutorID: req.ExecutorID, + Name: req.Name, + McpPolicy: req.McpPolicy, + Config: req.Config, + PrepareScript: req.PrepareScript, + CleanupScript: req.CleanupScript, + EnvVars: req.EnvVars, + }) + if err != nil { + h.logger.Error("failed to create executor profile", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create profile", nil) + } + return ws.NewResponse(msg.ID, msg.Action, dto.FromExecutorProfile(profile)) +} + +type wsGetProfileRequest struct { + ID string `json:"id"` +} + +func (h *ExecutorProfileHandlers) wsGetProfile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetProfileRequest + 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) + } + profile, err := h.service.GetExecutorProfile(ctx, req.ID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Profile not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, dto.FromExecutorProfile(profile)) +} + +type wsUpdateProfileRequest struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + McpPolicy *string `json:"mcp_policy,omitempty"` + Config map[string]string `json:"config,omitempty"` + PrepareScript *string `json:"prepare_script,omitempty"` + CleanupScript *string `json:"cleanup_script,omitempty"` + EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"` +} + +func (h *ExecutorProfileHandlers) wsUpdateProfile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateProfileRequest + 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) + } + profile, err := h.service.UpdateExecutorProfile(ctx, req.ID, &service.UpdateExecutorProfileRequest{ + Name: req.Name, + McpPolicy: req.McpPolicy, + Config: req.Config, + PrepareScript: req.PrepareScript, + CleanupScript: req.CleanupScript, + EnvVars: req.EnvVars, + }) + if err != nil { + h.logger.Error("failed to update executor profile", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update profile", nil) + } + return ws.NewResponse(msg.ID, msg.Action, dto.FromExecutorProfile(profile)) +} + +func (h *ExecutorProfileHandlers) wsDeleteProfile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetProfileRequest + 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 err := h.service.DeleteExecutorProfile(ctx, req.ID); err != nil { + h.logger.Error("failed to delete executor profile", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete profile", nil) + } + return ws.NewResponse(msg.ID, msg.Action, dto.SuccessResponse{Success: true}) +} diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 668789785c..46ff959c8d 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -389,6 +389,24 @@ func (m *mockRepository) CountTasksByWorkflowStep(ctx context.Context, stepID st return 0, nil } +// Executor profile operations +func (m *mockRepository) CreateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error { + return nil +} +func (m *mockRepository) GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) { + return nil, nil +} +func (m *mockRepository) UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error { + return nil +} +func (m *mockRepository) DeleteExecutorProfile(ctx context.Context, id string) error { return nil } +func (m *mockRepository) ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) { + return nil, nil +} +func (m *mockRepository) ListAllExecutorProfiles(ctx context.Context) ([]*models.ExecutorProfile, error) { + return nil, nil +} + func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", @@ -424,7 +442,7 @@ func getExecutionStore(t *testing.T, mgr *lifecycle.Manager) *lifecycle.Executio func newLifecycleManager(t *testing.T, log *logger.Logger) *lifecycle.Manager { t.Helper() reg := registry.NewRegistry(log) - return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, nil, lifecycle.RuntimeFallbackWarn, log) + return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackWarn, log) } func newAgentctlClient(t *testing.T, serverURL string, log *logger.Logger) *agentctlclient.Client { @@ -548,12 +566,12 @@ func TestStopProcessRejectsDifferentSession(t *testing.T) { svc := service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{}) lifecycleMgr := newLifecycleManager(t, log) client := newAgentctlClient(t, server.URL, log) - exec := (&lifecycle.RuntimeInstance{ + exec := (&lifecycle.ExecutorInstance{ InstanceID: "exec-1", TaskID: "task-1", SessionID: "session-a", Client: client, - }).ToAgentExecution(&lifecycle.RuntimeCreateRequest{ + }).ToAgentExecution(&lifecycle.ExecutorCreateRequest{ InstanceID: "exec-1", TaskID: "task-1", SessionID: "session-a", @@ -607,12 +625,12 @@ func TestStopProcessAgentctlUnavailable(t *testing.T) { svc := service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{}) lifecycleMgr := newLifecycleManager(t, log) client := newAgentctlClient(t, server.URL, log) - exec := (&lifecycle.RuntimeInstance{ + exec := (&lifecycle.ExecutorInstance{ InstanceID: "exec-1", TaskID: "task-1", SessionID: "session-a", Client: client, - }).ToAgentExecution(&lifecycle.RuntimeCreateRequest{ + }).ToAgentExecution(&lifecycle.ExecutorCreateRequest{ InstanceID: "exec-1", TaskID: "task-1", SessionID: "session-a", diff --git a/apps/backend/internal/task/handlers/script_placeholders.go b/apps/backend/internal/task/handlers/script_placeholders.go new file mode 100644 index 0000000000..128b298c50 --- /dev/null +++ b/apps/backend/internal/task/handlers/script_placeholders.go @@ -0,0 +1,11 @@ +package handlers + +import "github.com/kandev/kandev/internal/scriptengine" + +// ScriptPlaceholder describes a template variable available in prepare/cleanup scripts. +// +// Deprecated: Use scriptengine.PlaceholderInfo directly. +type ScriptPlaceholder = scriptengine.PlaceholderInfo + +// scriptPlaceholders is the registry of all available script template placeholders. +var scriptPlaceholders = scriptengine.DefaultPlaceholders diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 1dab9a8f7c..2be574716d 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -24,15 +24,11 @@ type TaskHandlers struct { type OrchestratorStarter interface { // StartTask starts agent execution for a task. - // If workflowStepID is provided, prompt prefix/suffix and plan mode from the step are applied. - // If planMode is true and the step doesn't already enable it, default plan mode is injected. - StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) + StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) // PrepareTaskSession creates a session entry without launching the agent. - // Returns the session ID immediately so it can be returned in the HTTP response. - PrepareTaskSession(ctx context.Context, taskID string, agentProfileID string, executorID string, workflowStepID string) (string, error) + PrepareTaskSession(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, workflowStepID string) (string, error) // StartTaskWithSession starts agent execution for a task using a pre-created session. - // If planMode is true and the step doesn't already enable it, default plan mode is injected. - StartTaskWithSession(ctx context.Context, taskID string, sessionID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) + StartTaskWithSession(ctx context.Context, taskID string, sessionID string, agentProfileID string, executorID string, executorProfileID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) } func NewTaskHandlers(svc *service.Service, orchestrator OrchestratorStarter, repo repository.Repository, planService *service.PlanService, log *logger.Logger) *TaskHandlers { diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 3186a3a07a..e9c1048667 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -252,21 +252,22 @@ type httpTaskRepositoryInput struct { } 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"` + 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"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` } type createTaskResponse struct { @@ -359,7 +360,7 @@ func (h *TaskHandlers) handlePostCreateTaskSession( 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) + sessionID, err := h.orchestrator.PrepareTaskSession(c.Request.Context(), taskID, body.AgentProfileID, body.ExecutorID, body.ExecutorProfileID, resolvedStepID) if err != nil { h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", taskID)) } else { @@ -381,7 +382,7 @@ func (h *TaskHandlers) startAgentForNewTask( 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) + sessionID, err := h.orchestrator.PrepareTaskSession(ctx, taskID, body.AgentProfileID, body.ExecutorID, body.ExecutorProfileID, 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 @@ -398,7 +399,7 @@ func (h *TaskHandlers) startAgentForNewTask( 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) + execution, err := h.orchestrator.StartTaskWithSession(startCtx, taskID, sessionID, body.AgentProfileID, executorID, body.ExecutorProfileID, 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 diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index b9d119102f..f612d00d07 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -73,20 +73,21 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me } 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"` + 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"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` } func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -147,7 +148,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M 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 taskDTO.WorkflowStepID (backend-resolved) instead of req.WorkflowStepID - execution, err := h.orchestrator.StartTask(ctx, taskDTO.ID, req.AgentProfileID, req.ExecutorID, req.Priority, taskDTO.Description, taskDTO.WorkflowStepID, req.PlanMode) + execution, err := h.orchestrator.StartTask(ctx, taskDTO.ID, req.AgentProfileID, req.ExecutorID, req.ExecutorProfileID, req.Priority, taskDTO.Description, taskDTO.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) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index d01316c5b7..fe8fb61911 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -209,6 +209,7 @@ type TaskSession struct { ContainerID string `json:"container_id"` // Docker container ID for cleanup AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used ExecutorID string `json:"executor_id"` + ExecutorProfileID string `json:"executor_profile_id"` EnvironmentID string `json:"environment_id"` RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) @@ -235,19 +236,20 @@ type TaskSession struct { // TODO: Add v1.TaskSession type to pkg/api/v1/ func (s *TaskSession) ToAPI() map[string]interface{} { result := map[string]interface{}{ - "id": s.ID, - "task_id": s.TaskID, - "agent_execution_id": s.AgentExecutionID, - "container_id": s.ContainerID, - "agent_profile_id": s.AgentProfileID, - "executor_id": s.ExecutorID, - "environment_id": s.EnvironmentID, - "repository_id": s.RepositoryID, - "base_branch": s.BaseBranch, - "worktrees": s.Worktrees, - "state": string(s.State), - "started_at": s.StartedAt, - "updated_at": s.UpdatedAt, + "id": s.ID, + "task_id": s.TaskID, + "agent_execution_id": s.AgentExecutionID, + "container_id": s.ContainerID, + "agent_profile_id": s.AgentProfileID, + "executor_id": s.ExecutorID, + "executor_profile_id": s.ExecutorProfileID, + "environment_id": s.EnvironmentID, + "repository_id": s.RepositoryID, + "base_branch": s.BaseBranch, + "worktrees": s.Worktrees, + "state": string(s.State), + "started_at": s.StartedAt, + "updated_at": s.UpdatedAt, } // For backward compatibility, populate worktree_path and worktree_branch from first worktree if len(s.Worktrees) > 0 { @@ -323,12 +325,14 @@ const ( ExecutorTypeWorktree ExecutorType = "worktree" ExecutorTypeLocalDocker ExecutorType = "local_docker" ExecutorTypeRemoteDocker ExecutorType = "remote_docker" + ExecutorTypeSprites ExecutorType = "sprites" ) const ( ExecutorIDLocal = "exec-local" ExecutorIDWorktree = "exec-worktree" ExecutorIDLocalDocker = "exec-local-docker" + ExecutorIDSprites = "exec-sprites" ) // ExecutorStatus represents executor availability. @@ -378,6 +382,28 @@ type ExecutorRunning struct { UpdatedAt time.Time `json:"updated_at"` } +// ProfileEnvVar represents an environment variable for an executor profile. +// Either Value (plain text) or SecretID (reference to a secret) should be set, not both. +type ProfileEnvVar struct { + Key string `json:"key"` + Value string `json:"value,omitempty"` + SecretID string `json:"secret_id,omitempty"` +} + +// ExecutorProfile represents a named configuration preset for an executor. +type ExecutorProfile struct { + ID string `json:"id"` + ExecutorID string `json:"executor_id"` + Name string `json:"name"` + McpPolicy string `json:"mcp_policy,omitempty"` + Config map[string]string `json:"config,omitempty"` + PrepareScript string `json:"prepare_script,omitempty"` + CleanupScript string `json:"cleanup_script,omitempty"` + EnvVars []ProfileEnvVar `json:"env_vars,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // EnvironmentKind represents the runtime type for environments. type EnvironmentKind string diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 0b7b04f9d8..8aa6f13181 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -133,6 +133,14 @@ type Repository interface { DeleteExecutor(ctx context.Context, id string) error ListExecutors(ctx context.Context) ([]*models.Executor, error) + // Executor profile operations + CreateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error + GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) + UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error + DeleteExecutorProfile(ctx context.Context, id string) error + ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) + ListAllExecutorProfiles(ctx context.Context) ([]*models.ExecutorProfile, error) + // Executor running operations ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 83a9ff7359..c955b7f840 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -74,6 +74,12 @@ func (r *Repository) initSchema() error { if err := r.initReviewSchema(); err != nil { return err } + if err := r.migrateExecutorProfiles(); err != nil { + return err + } + if err := r.migrateTaskSessions(); err != nil { + return err + } if err := r.ensureDefaultWorkspace(); err != nil { return err } @@ -86,6 +92,21 @@ func (r *Repository) initSchema() error { return r.ensureWorkspaceIndexes() } +// migrateExecutorProfiles adds mcp_policy column and drops is_default from executor_profiles. +func (r *Repository) migrateExecutorProfiles() error { + // Add mcp_policy column if it doesn't exist + _, _ = r.db.Exec(`ALTER TABLE executor_profiles ADD COLUMN mcp_policy TEXT DEFAULT ''`) + // Drop is_default column - SQLite doesn't support DROP COLUMN before 3.35.0, + // so we just ignore the old column if present. New schema omits it. + return nil +} + +// migrateTaskSessions adds new columns to task_sessions. +func (r *Repository) migrateTaskSessions() error { + _, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN executor_profile_id TEXT DEFAULT ''`) + return nil +} + // runMigrations applies idempotent ALTER TABLE migrations for schema evolution. func (r *Repository) runMigrations() error { // Add last_message_uuid column to executors_running (ignore error if already exists) @@ -153,6 +174,20 @@ func (r *Repository) initInfraSchema() error { updated_at TIMESTAMP NOT NULL ); + CREATE TABLE IF NOT EXISTS executor_profiles ( + id TEXT PRIMARY KEY, + executor_id TEXT NOT NULL, + name TEXT NOT NULL, + mcp_policy TEXT DEFAULT '', + config TEXT DEFAULT '{}', + prepare_script TEXT DEFAULT '', + cleanup_script TEXT DEFAULT '', + env_vars TEXT DEFAULT '[]', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (executor_id) REFERENCES executors(id) + ); + CREATE TABLE IF NOT EXISTS environments ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -337,6 +372,7 @@ func (r *Repository) initSessionWorktreeSchema() error { container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT NOT NULL, executor_id TEXT DEFAULT '', + executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', repository_id TEXT DEFAULT '', base_branch TEXT DEFAULT '', diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index a7fb840ece..34f5cef72e 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -105,6 +105,9 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error { if err := r.ensureDefaultExecutors(ctx); err != nil { return err } + if err := r.ensureDefaultExecutorProfiles(ctx); err != nil { + return err + } return r.ensureDefaultEnvironment(ctx) } @@ -141,6 +144,7 @@ func (r *Repository) insertDefaultExecutors(ctx context.Context) error { {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()}}, + {id: models.ExecutorIDSprites, name: "Sprites.dev", execType: models.ExecutorTypeSprites, status: models.ExecutorStatusDisabled, isSystem: false, resumable: false, config: map[string]string{}}, } for _, executor := range executors { configJSON, err := json.Marshal(executor.config) @@ -157,6 +161,35 @@ func (r *Repository) insertDefaultExecutors(ctx context.Context) error { return nil } +func (r *Repository) ensureDefaultExecutorProfiles(ctx context.Context) error { + profileSeeds := []struct { + executorID string + name string + }{ + {models.ExecutorIDLocal, "Local"}, + {models.ExecutorIDWorktree, "Worktree"}, + } + for _, seed := range profileSeeds { + var profileCount int + if err := r.db.QueryRowContext(ctx, r.db.Rebind( + "SELECT COUNT(1) FROM executor_profiles WHERE executor_id = ?", + ), seed.executorID).Scan(&profileCount); err != nil { + return err + } + if profileCount == 0 { + now := time.Now().UTC() + id := uuid.New().String() + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO executor_profiles (id, executor_id, name, mcp_policy, config, prepare_script, cleanup_script, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), id, seed.executorID, seed.name, "", "{}", "", "", 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 { diff --git a/apps/backend/internal/task/repository/sqlite/executor_profile.go b/apps/backend/internal/task/repository/sqlite/executor_profile.go new file mode 100644 index 0000000000..9d3b4a93f4 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/executor_profile.go @@ -0,0 +1,166 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/task/models" +) + +const jsonNull = "null" + +// Executor profile operations + +func (r *Repository) CreateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error { + if profile.ID == "" { + profile.ID = uuid.New().String() + } + now := time.Now().UTC() + profile.CreatedAt = now + profile.UpdatedAt = now + + configJSON, err := json.Marshal(profile.Config) + if err != nil { + return fmt.Errorf("failed to serialize profile config: %w", err) + } + + envVarsJSON, err := json.Marshal(profile.EnvVars) + if err != nil { + return fmt.Errorf("failed to serialize profile env_vars: %w", err) + } + + _, err = r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO executor_profiles (id, executor_id, name, mcp_policy, config, prepare_script, cleanup_script, env_vars, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), profile.ID, profile.ExecutorID, profile.Name, profile.McpPolicy, string(configJSON), profile.PrepareScript, profile.CleanupScript, string(envVarsJSON), profile.CreatedAt, profile.UpdatedAt) + return err +} + +func (r *Repository) GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) { + profile := &models.ExecutorProfile{} + var configJSON string + var envVarsJSON sql.NullString + + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id, executor_id, name, mcp_policy, config, prepare_script, cleanup_script, env_vars, created_at, updated_at + FROM executor_profiles WHERE id = ? + `), id).Scan( + &profile.ID, &profile.ExecutorID, &profile.Name, &profile.McpPolicy, + &configJSON, &profile.PrepareScript, &profile.CleanupScript, &envVarsJSON, &profile.CreatedAt, &profile.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("executor profile not found: %s", id) + } + if err != nil { + return nil, err + } + + if configJSON != "" && configJSON != "{}" { + if err := json.Unmarshal([]byte(configJSON), &profile.Config); err != nil { + return nil, fmt.Errorf("failed to deserialize profile config: %w", err) + } + } + if envVarsJSON.Valid && envVarsJSON.String != "" && envVarsJSON.String != jsonNull { + if err := json.Unmarshal([]byte(envVarsJSON.String), &profile.EnvVars); err != nil { + return nil, fmt.Errorf("failed to deserialize profile env_vars: %w", err) + } + } + return profile, nil +} + +func (r *Repository) UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error { + profile.UpdatedAt = time.Now().UTC() + + configJSON, err := json.Marshal(profile.Config) + if err != nil { + return fmt.Errorf("failed to serialize profile config: %w", err) + } + + envVarsJSON, err := json.Marshal(profile.EnvVars) + if err != nil { + return fmt.Errorf("failed to serialize profile env_vars: %w", err) + } + + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE executor_profiles SET name = ?, mcp_policy = ?, config = ?, prepare_script = ?, cleanup_script = ?, env_vars = ?, updated_at = ? + WHERE id = ? + `), profile.Name, profile.McpPolicy, string(configJSON), profile.PrepareScript, profile.CleanupScript, string(envVarsJSON), profile.UpdatedAt, profile.ID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("executor profile not found: %s", profile.ID) + } + return nil +} + +func (r *Repository) DeleteExecutorProfile(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM executor_profiles WHERE id = ?`), id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("executor profile not found: %s", id) + } + return nil +} + +func (r *Repository) ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT id, executor_id, name, mcp_policy, config, prepare_script, cleanup_script, env_vars, created_at, updated_at + FROM executor_profiles WHERE executor_id = ? ORDER BY name ASC + `), executorID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return scanExecutorProfiles(rows) +} + +func (r *Repository) ListAllExecutorProfiles(ctx context.Context) ([]*models.ExecutorProfile, error) { + rows, err := r.ro.QueryContext(ctx, ` + SELECT id, executor_id, name, mcp_policy, config, prepare_script, cleanup_script, env_vars, created_at, updated_at + FROM executor_profiles ORDER BY executor_id ASC, name ASC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return scanExecutorProfiles(rows) +} + +func scanExecutorProfiles(rows *sql.Rows) ([]*models.ExecutorProfile, error) { + var result []*models.ExecutorProfile + for rows.Next() { + profile := &models.ExecutorProfile{} + var configJSON string + var envVarsJSON sql.NullString + if err := rows.Scan( + &profile.ID, &profile.ExecutorID, &profile.Name, &profile.McpPolicy, + &configJSON, &profile.PrepareScript, &profile.CleanupScript, &envVarsJSON, &profile.CreatedAt, &profile.UpdatedAt, + ); err != nil { + return nil, err + } + if configJSON != "" && configJSON != "{}" { + if err := json.Unmarshal([]byte(configJSON), &profile.Config); err != nil { + return nil, fmt.Errorf("failed to deserialize profile config: %w", err) + } + } + if envVarsJSON.Valid && envVarsJSON.String != "" && envVarsJSON.String != jsonNull { + if err := json.Unmarshal([]byte(envVarsJSON.String), &profile.EnvVars); err != nil { + return nil, fmt.Errorf("failed to deserialize profile env_vars: %w", err) + } + } + result = append(result, profile) + } + return result, rows.Err() +} diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index d727d75148..e3808e3abf 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -192,14 +192,14 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task } _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_sessions ( - id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, + id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, - session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, + session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt, @@ -236,7 +236,7 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr err := row.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, - &session.ExecutorID, &session.EnvironmentID, + &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, @@ -290,7 +290,7 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr // GetTaskSession retrieves an agent session by ID func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_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, @@ -303,7 +303,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas // GetTaskSessionByTaskID retrieves the most recent agent session for a task func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_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, @@ -316,7 +316,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) // GetActiveTaskSessionByTaskID retrieves the active (running/waiting) agent session for a task func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_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, @@ -355,13 +355,13 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET - agent_execution_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?, + agent_execution_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?, repository_id = ?, base_branch = ?, agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?, is_primary = ?, workflow_step_id = ?, review_status = ?, is_passthrough = ? WHERE id = ? - `), session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, + `), session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt, @@ -422,7 +422,7 @@ func (r *Repository) ClearSessionExecutionID(ctx context.Context, id string) err // ListTaskSessions returns all agent sessions for a task func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_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, @@ -451,7 +451,7 @@ func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*mo // ListActiveTaskSessions returns all active agent sessions across all tasks func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { rows, err := r.ro.QueryContext(ctx, ` - SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_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, @@ -480,7 +480,7 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task // ListActiveTaskSessionsByTaskID returns all active agent sessions for a specific task func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_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, @@ -578,7 +578,7 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { err := rows.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, - &session.ExecutorID, &session.EnvironmentID, + &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, @@ -734,7 +734,7 @@ 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) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, executor_profile_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, diff --git a/apps/backend/internal/task/service/default_scripts.go b/apps/backend/internal/task/service/default_scripts.go new file mode 100644 index 0000000000..377d2f319d --- /dev/null +++ b/apps/backend/internal/task/service/default_scripts.go @@ -0,0 +1,13 @@ +package service + +import ( + "github.com/kandev/kandev/internal/scriptengine" + "github.com/kandev/kandev/internal/task/models" +) + +// DefaultPrepareScript returns the default prepare script for a given executor type. +// +// Deprecated: Use scriptengine.DefaultPrepareScript directly. +func DefaultPrepareScript(executorType models.ExecutorType) string { + return scriptengine.DefaultPrepareScript(string(executorType)) +} diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index f35e95b39a..3b17db7398 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -144,6 +144,24 @@ func (s *Service) publishExecutorEvent(ctx context.Context, eventType string, ex s.publishEventToBus(ctx, eventType, "executor", executor.ID, data) } +func (s *Service) publishExecutorProfileEvent(ctx context.Context, eventType string, profile *models.ExecutorProfile) { + if s.eventBus == nil || profile == nil { + return + } + data := map[string]interface{}{ + "id": profile.ID, + "executor_id": profile.ExecutorID, + "name": profile.Name, + "mcp_policy": profile.McpPolicy, + "config": profile.Config, + "prepare_script": profile.PrepareScript, + "cleanup_script": profile.CleanupScript, + "created_at": profile.CreatedAt.Format(time.RFC3339), + "updated_at": profile.UpdatedAt.Format(time.RFC3339), + } + s.publishEventToBus(ctx, eventType, "executor_profile", profile.ID, data) +} + func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, environment *models.Environment) { if s.eventBus == nil || environment == nil { return diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 93d66b162c..a9621398b9 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -129,6 +129,27 @@ type UpdateExecutorRequest struct { Config map[string]string `json:"config,omitempty"` } +// CreateExecutorProfileRequest contains the data for creating an executor profile +type CreateExecutorProfileRequest struct { + ExecutorID string `json:"executor_id"` + Name string `json:"name"` + McpPolicy string `json:"mcp_policy"` + Config map[string]string `json:"config,omitempty"` + PrepareScript string `json:"prepare_script"` + CleanupScript string `json:"cleanup_script"` + EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"` +} + +// UpdateExecutorProfileRequest contains the data for updating an executor profile +type UpdateExecutorProfileRequest struct { + Name *string `json:"name,omitempty"` + McpPolicy *string `json:"mcp_policy,omitempty"` + Config map[string]string `json:"config,omitempty"` + PrepareScript *string `json:"prepare_script,omitempty"` + CleanupScript *string `json:"cleanup_script,omitempty"` + EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"` +} + // CreateEnvironmentRequest contains the data for creating an environment type CreateEnvironmentRequest struct { Name string `json:"name"` diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 0abe88ff77..8377da103f 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -518,6 +518,89 @@ func (s *Service) ListExecutors(ctx context.Context) ([]*models.Executor, error) return s.repo.ListExecutors(ctx) } +// Executor Profile operations + +func (s *Service) CreateExecutorProfile(ctx context.Context, req *CreateExecutorProfileRequest) (*models.ExecutorProfile, error) { + if req.Name == "" { + return nil, fmt.Errorf("profile name is required") + } + if req.ExecutorID == "" { + return nil, fmt.Errorf("executor_id is required") + } + // Verify executor exists + if _, err := s.repo.GetExecutor(ctx, req.ExecutorID); err != nil { + return nil, fmt.Errorf("executor not found: %w", err) + } + profile := &models.ExecutorProfile{ + ExecutorID: req.ExecutorID, + Name: req.Name, + McpPolicy: req.McpPolicy, + Config: req.Config, + PrepareScript: req.PrepareScript, + CleanupScript: req.CleanupScript, + EnvVars: req.EnvVars, + } + if err := s.repo.CreateExecutorProfile(ctx, profile); err != nil { + return nil, err + } + s.publishExecutorProfileEvent(ctx, events.ExecutorProfileCreated, profile) + return profile, nil +} + +func (s *Service) GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) { + return s.repo.GetExecutorProfile(ctx, id) +} + +func (s *Service) UpdateExecutorProfile(ctx context.Context, id string, req *UpdateExecutorProfileRequest) (*models.ExecutorProfile, error) { + profile, err := s.repo.GetExecutorProfile(ctx, id) + if err != nil { + return nil, err + } + if req.Name != nil { + profile.Name = *req.Name + } + if req.McpPolicy != nil { + profile.McpPolicy = *req.McpPolicy + } + if req.Config != nil { + profile.Config = req.Config + } + if req.PrepareScript != nil { + profile.PrepareScript = *req.PrepareScript + } + if req.CleanupScript != nil { + profile.CleanupScript = *req.CleanupScript + } + if req.EnvVars != nil { + profile.EnvVars = req.EnvVars + } + if err := s.repo.UpdateExecutorProfile(ctx, profile); err != nil { + return nil, err + } + s.publishExecutorProfileEvent(ctx, events.ExecutorProfileUpdated, profile) + return profile, nil +} + +func (s *Service) DeleteExecutorProfile(ctx context.Context, id string) error { + profile, err := s.repo.GetExecutorProfile(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteExecutorProfile(ctx, id); err != nil { + return err + } + s.publishExecutorProfileEvent(ctx, events.ExecutorProfileDeleted, profile) + return nil +} + +func (s *Service) ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) { + return s.repo.ListExecutorProfiles(ctx, executorID) +} + +func (s *Service) ListAllExecutorProfiles(ctx context.Context) ([]*models.ExecutorProfile, error) { + return s.repo.ListAllExecutorProfiles(ctx) +} + // Environment operations func (s *Service) CreateEnvironment(ctx context.Context, req *CreateEnvironmentRequest) (*models.Environment, error) { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 040191408d..6e21ea52a6 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -40,6 +40,14 @@ const ( ActionExecutorUpdate = "executor.update" ActionExecutorDelete = "executor.delete" + // Executor profile actions + ActionExecutorProfileList = "executor.profile.list" + ActionExecutorProfileListAll = "executor.profile.list_all" + ActionExecutorProfileCreate = "executor.profile.create" + ActionExecutorProfileGet = "executor.profile.get" + ActionExecutorProfileUpdate = "executor.profile.update" + ActionExecutorProfileDelete = "executor.profile.delete" + // Environment actions ActionEnvironmentList = "environment.list" ActionEnvironmentCreate = "environment.create" @@ -166,6 +174,11 @@ const ( ActionEnvironmentCreated = "environment.created" ActionEnvironmentUpdated = "environment.updated" ActionEnvironmentDeleted = "environment.deleted" + ActionExecutorProfileCreated = "executor.profile.created" + ActionExecutorProfileUpdated = "executor.profile.updated" + ActionExecutorProfileDeleted = "executor.profile.deleted" + ActionExecutorPrepareProgress = "executor.prepare.progress" + ActionExecutorPrepareCompleted = "executor.prepare.completed" ActionAgentProfileDeleted = "agent.profile.deleted" ActionAgentProfileCreated = "agent.profile.created" @@ -236,6 +249,21 @@ const ( ActionVscodeStatus = "vscode.status" // Get code-server status for a session ActionVscodeOpenFile = "vscode.openFile" // Open a file in code-server for a session + // Secret actions + ActionSecretList = "secrets.list" + ActionSecretCreate = "secrets.create" + ActionSecretUpdate = "secrets.update" + ActionSecretDelete = "secrets.delete" + ActionSecretReveal = "secrets.reveal" + + // Sprites actions + ActionSpritesStatus = "sprites.status" + ActionSpritesInstancesList = "sprites.instances.list" + ActionSpritesInstancesDestroy = "sprites.instances.destroy" + ActionSpritesTest = "sprites.test" + ActionSpritesNetworkPolicyGet = "sprites.network_policy.get" + ActionSpritesNetworkPolicyUpdate = "sprites.network_policy.update" + // MCP tool actions (agentctl -> backend via WS tunnel) ActionMCPListWorkspaces = "mcp.list_workspaces" ActionMCPListWorkflows = "mcp.list_workflows" diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 45fcab1ee1..4247c39c8c 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -388,7 +388,7 @@ importers: version: 0.55.1 next: specifier: 16.1.1 - version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -427,7 +427,7 @@ importers: version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) styled-jsx: specifier: 5.1.6 - version: 5.1.6(react@19.2.3) + version: 5.1.6(@babel/core@7.28.6)(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -1178,89 +1178,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1454,24 +1470,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.1': resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.1': resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.1': resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.1': resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} @@ -2303,66 +2323,79 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -2480,24 +2513,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3133,41 +3170,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4852,24 +4897,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -10203,8 +10252,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10226,7 +10275,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10237,22 +10286,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10263,7 +10312,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11724,7 +11773,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 @@ -11733,7 +11782,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.1 '@next/swc-darwin-x64': 16.1.1 @@ -12831,10 +12880,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 + optionalDependencies: + '@babel/core': 7.28.6 stylis@4.3.6: {} diff --git a/apps/web/app/actions/environments.ts b/apps/web/app/actions/environments.ts deleted file mode 100644 index 0ccb1ca22a..0000000000 --- a/apps/web/app/actions/environments.ts +++ /dev/null @@ -1,69 +0,0 @@ -"use server"; - -import { getBackendConfig } from "@/lib/config"; -import type { Environment, ListEnvironmentsResponse } from "@/lib/types/http"; - -const { apiBaseUrl } = getBackendConfig(); - -async function fetchJson(url: string, options?: RequestInit): Promise { - const response = await fetch(url, { - ...options, - cache: "no-store", - headers: { - "Content-Type": "application/json", - ...(options?.headers ?? {}), - }, - }); - if (!response.ok) { - throw new Error(`Request failed: ${response.status} ${response.statusText}`); - } - if (response.status === 204) { - return undefined as T; - } - const text = await response.text(); - if (!text) { - return undefined as T; - } - return JSON.parse(text) as T; -} - -export async function listEnvironmentsAction(): Promise { - return fetchJson(`${apiBaseUrl}/api/v1/environments`); -} - -export async function getEnvironmentAction(id: string): Promise { - return fetchJson(`${apiBaseUrl}/api/v1/environments/${id}`); -} - -export async function createEnvironmentAction(payload: { - name: string; - kind: string; - worktree_root?: string; - image_tag?: string; - dockerfile?: string; - build_config?: Record; -}): Promise { - return fetchJson(`${apiBaseUrl}/api/v1/environments`, { - method: "POST", - body: JSON.stringify(payload), - }); -} - -export async function updateEnvironmentAction( - id: string, - payload: Partial< - Pick< - Environment, - "name" | "kind" | "worktree_root" | "image_tag" | "dockerfile" | "build_config" - > - >, -): Promise { - return fetchJson(`${apiBaseUrl}/api/v1/environments/${id}`, { - method: "PATCH", - body: JSON.stringify(payload), - }); -} - -export async function deleteEnvironmentAction(id: string) { - await fetchJson(`${apiBaseUrl}/api/v1/environments/${id}`, { method: "DELETE" }); -} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 5cf4c9027c..b72dce1638 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -104,7 +104,7 @@ /* Grey scale: #181818 bg → #1F1F1F card → #2a2a2a borders → #333 hover */ --background: #181818; --foreground: #d4d4d4; - --card: #1F1F1F; + --card: #1f1f1f; --card-foreground: #d4d4d4; --popover: #222222; --popover-foreground: #d4d4d4; diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index 456066d605..37db7854cc 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -208,7 +208,6 @@ function buildSessionPageState(p: BuildSessionPageStateParams) { settingsData: { agentsLoaded: true, executorsLoaded: false, - environmentsLoaded: false, }, availableAgents: { items: availableAgents, loaded: true, loading: false }, userSettings: buildUserSettingsFromResponse(userSettingsResponse), diff --git a/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx b/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx index c2ec7b3004..cd9df77a9f 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx +++ b/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx @@ -16,12 +16,7 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator"; import { ProfileFormFields } from "@/components/settings/profile-form-fields"; -import type { - Agent, - ModelConfig, - PermissionSetting, - PassthroughConfig, -} from "@/lib/types/http"; +import type { Agent, ModelConfig, PermissionSetting, PassthroughConfig } from "@/lib/types/http"; import { ProfileMcpConfigCard } from "./profile-mcp-config-card"; import type { DraftProfile, DraftAgent } from "./agent-save-helpers"; diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index c0df44da9a..e98b8e7e99 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -7,7 +7,13 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; import { useToast } from "@/components/toast-provider"; -import type { Agent, AgentDiscovery, AvailableAgent, PermissionSetting, ModelConfig } from "@/lib/types/http"; +import type { + Agent, + AgentDiscovery, + AvailableAgent, + PermissionSetting, + ModelConfig, +} from "@/lib/types/http"; import { buildDefaultPermissions } from "@/lib/agent-permissions"; import { generateUUID } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; diff --git a/apps/web/app/settings/environment/[id]/page.tsx b/apps/web/app/settings/environment/[id]/page.tsx deleted file mode 100644 index c61ec7732d..0000000000 --- a/apps/web/app/settings/environment/[id]/page.tsx +++ /dev/null @@ -1,372 +0,0 @@ -"use client"; - -import { use, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; -import { IconTrash } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@kandev/ui/card"; -import { Input } from "@kandev/ui/input"; -import { Label } from "@kandev/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { Separator } from "@kandev/ui/separator"; -import { Textarea } from "@kandev/ui/textarea"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@kandev/ui/dialog"; -import { updateEnvironmentAction, deleteEnvironmentAction } from "@/app/actions/environments"; -import { getWebSocketClient } from "@/lib/ws/connection"; -import type { Environment } from "@/lib/types/http"; -import type { BaseDocker } from "@/lib/settings/types"; -import { useAppStore } from "@/components/state-provider"; - -const ENVIRONMENTS_ROUTE = "/settings/environments"; - -const BASE_IMAGE_LABELS: Record = { - universal: "Universal (Ubuntu)", - golang: "Golang", - node: "Node.js", - python: "Python", -}; - -export default function EnvironmentEditPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); - const router = useRouter(); - const environment = useAppStore( - (state) => state.environments.items.find((item: Environment) => item.id === id) ?? null, - ); - - if (!environment) { - return ( -
- - -

Environment not found

- -
-
-
- ); - } - - return ; -} - -type EnvironmentEditFormProps = { - environment: Environment; -}; - -type DeleteDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - onDelete: () => void; - isDeleting: boolean; - confirmText: string; - onConfirmTextChange: (value: string) => void; -}; - -function DeleteEnvironmentDialog({ - open, - onOpenChange, - onDelete, - isDeleting, - confirmText, - onConfirmTextChange, -}: DeleteDialogProps) { - return ( - - - - Delete Environment - - Type "delete" to confirm deletion. This action cannot be undone. - - -
- - onConfirmTextChange(event.target.value)} - placeholder="delete" - /> -
- - - - -
-
- ); -} - -function EnvironmentDetailsCard({ - draft, - isSystem, - onDraftChange, -}: { - draft: Environment; - isSystem: boolean; - onDraftChange: (next: Environment) => void; -}) { - return ( - - - Environment Details - - {isSystem - ? "System environments only allow worktree configuration." - : "Customize the base image and worktree settings."} - - - -
- - onDraftChange({ ...draft, name: event.target.value })} - disabled={isSystem} - /> - {isSystem && ( -

- System environment names cannot be edited. -

- )} -
- {!isSystem && ( -
- - -
- )} -
- - - onDraftChange({ ...draft, worktree_root: event.target.value || null }) - } - placeholder="/workspace" - /> -
-
-
- ); -} - -function DockerfileCard({ - dockerfile, - onChange, -}: { - dockerfile: string; - onChange: (value: string) => void; -}) { - return ( - - - Custom Dockerfile - - - -