From f2aea364aff21e703cf33e1072ea2e078822fd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 13 Jan 2026 00:25:47 +0000 Subject: [PATCH] feat: environments and executors --- apps/backend/cmd/kandev/main.go | 10 +- apps/backend/internal/events/types.go | 14 + .../gateway/websocket/task_notifications.go | 6 + .../internal/integration/integration_test.go | 4 + .../internal/integration/orchestrator_test.go | 4 + .../internal/task/controller/environment.go | 76 +++ .../internal/task/controller/executor.go | 73 +++ .../internal/task/controller/workspace.go | 12 +- apps/backend/internal/task/dto/dto.go | 90 ++- apps/backend/internal/task/dto/requests.go | 74 ++- .../task/handlers/environment_handlers.go | 268 +++++++++ .../task/handlers/executor_handlers.go | 274 +++++++++ .../task/handlers/workspace_handlers.go | 54 +- apps/backend/internal/task/models/models.go | 79 ++- .../internal/task/repository/interface.go | 14 + .../internal/task/repository/memory.go | 179 +++++- .../internal/task/repository/sqlite.go | 435 ++++++++++++-- apps/backend/internal/task/service/service.go | 275 ++++++++- apps/backend/pkg/api/v1/workspace.go | 23 +- apps/backend/pkg/websocket/actions.go | 20 + apps/web/app/actions/environments.ts | 64 +++ apps/web/app/actions/executors.ts | 62 ++ apps/web/app/actions/workspaces.ts | 11 +- .../app/settings/environment/[id]/page.tsx | 544 ++++++++---------- .../web/app/settings/environment/new/page.tsx | 216 +++++++ apps/web/app/settings/environments/page.tsx | 222 ++----- apps/web/app/settings/executor/[id]/page.tsx | 282 +++++++++ apps/web/app/settings/executor/new/page.tsx | 128 +++++ apps/web/app/settings/executors/page.tsx | 179 ++++++ apps/web/app/settings/general/page.tsx | 31 +- .../workspace/workspace-edit-client.tsx | 83 ++- .../components/settings/environment-card.tsx | 24 +- .../settings/settings-app-sidebar.tsx | 198 ++++++- apps/web/lib/settings/dummy-data.ts | 67 ++- apps/web/lib/settings/types.ts | 33 +- apps/web/lib/types/backend.ts | 37 +- apps/web/lib/types/http.ts | 34 ++ 37 files changed, 3558 insertions(+), 641 deletions(-) create mode 100644 apps/backend/internal/task/controller/environment.go create mode 100644 apps/backend/internal/task/controller/executor.go create mode 100644 apps/backend/internal/task/handlers/environment_handlers.go create mode 100644 apps/backend/internal/task/handlers/executor_handlers.go create mode 100644 apps/web/app/actions/environments.ts create mode 100644 apps/web/app/actions/executors.ts create mode 100644 apps/web/app/settings/environment/new/page.tsx create mode 100644 apps/web/app/settings/executor/[id]/page.tsx create mode 100644 apps/web/app/settings/executor/new/page.tsx create mode 100644 apps/web/app/settings/executors/page.tsx diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 15ab3f63c1..65b042e710 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -258,6 +258,8 @@ func main() { taskController.SetWorktreeLookup(worktreeMgr) // Enable worktree info in task responses } repositoryController := taskcontroller.NewRepositoryController(taskSvc) + executorController := taskcontroller.NewExecutorController(taskSvc) + environmentController := taskcontroller.NewEnvironmentController(taskSvc) // Create orchestrator controller and handlers (Pattern A) orchestratorCtrl := orchestratorcontroller.NewController(orchestratorSvc) @@ -417,9 +419,9 @@ func main() { // Broadcast comment.added notification to task subscribers notification, _ := ws.NewNotification(ws.ActionCommentAdded, map[string]interface{}{ - "task_id": taskID, - "comment": comment.ToAPI(), - "requests_input": true, + "task_id": taskID, + "comment": comment.ToAPI(), + "requests_input": true, }) gateway.Hub.BroadcastToTask(taskID, notification) @@ -507,6 +509,8 @@ func main() { taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) + taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) + taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) taskhandlers.RegisterCommentRoutes(router, gateway.Dispatcher, taskSvc, &orchestratorAdapter{svc: orchestratorSvc}, log) log.Info("Registered Task Service handlers (HTTP + WebSocket)") diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 66494d7fbe..5dccf74a57 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -49,6 +49,20 @@ const ( RepositoryScriptDeleted = "repository.script.deleted" ) +// Event types for executors +const ( + ExecutorCreated = "executor.created" + ExecutorUpdated = "executor.updated" + ExecutorDeleted = "executor.deleted" +) + +// Event types for environments +const ( + EnvironmentCreated = "environment.created" + EnvironmentUpdated = "environment.updated" + EnvironmentDeleted = "environment.deleted" +) + // Event types for agents const ( AgentStarted = "agent.started" diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index cc5b649cfa..495243ce9c 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -44,6 +44,12 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.RepositoryScriptCreated, ws.ActionRepositoryScriptCreated) b.subscribe(eventBus, events.RepositoryScriptUpdated, ws.ActionRepositoryScriptUpdated) b.subscribe(eventBus, events.RepositoryScriptDeleted, ws.ActionRepositoryScriptDeleted) + 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.EnvironmentCreated, ws.ActionEnvironmentCreated) + b.subscribe(eventBus, events.EnvironmentUpdated, ws.ActionEnvironmentUpdated) + b.subscribe(eventBus, events.EnvironmentDeleted, ws.ActionEnvironmentDeleted) go func() { <-ctx.Done() diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 146c898946..9825c438f1 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -76,9 +76,13 @@ func NewTestServer(t *testing.T) *TestServer { workspaceController := taskcontroller.NewWorkspaceController(taskSvc) boardController := taskcontroller.NewBoardController(taskSvc) taskController := taskcontroller.NewTaskController(taskSvc) + executorController := taskcontroller.NewExecutorController(taskSvc) + environmentController := taskcontroller.NewEnvironmentController(taskSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) + taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) + taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) // Create test server server := httptest.NewServer(router) diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index aaa9dc8230..a0743e4155 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -520,9 +520,13 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { workspaceController := taskcontroller.NewWorkspaceController(taskSvc) boardController := taskcontroller.NewBoardController(taskSvc) taskController := taskcontroller.NewTaskController(taskSvc) + executorController := taskcontroller.NewExecutorController(taskSvc) + environmentController := taskcontroller.NewEnvironmentController(taskSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) + taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) + taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) // Create test server server := httptest.NewServer(router) diff --git a/apps/backend/internal/task/controller/environment.go b/apps/backend/internal/task/controller/environment.go new file mode 100644 index 0000000000..490a28116a --- /dev/null +++ b/apps/backend/internal/task/controller/environment.go @@ -0,0 +1,76 @@ +package controller + +import ( + "context" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/service" +) + +type EnvironmentController struct { + service *service.Service +} + +func NewEnvironmentController(svc *service.Service) *EnvironmentController { + return &EnvironmentController{service: svc} +} + +func (c *EnvironmentController) ListEnvironments(ctx context.Context, _ dto.ListEnvironmentsRequest) (dto.ListEnvironmentsResponse, error) { + environments, err := c.service.ListEnvironments(ctx) + if err != nil { + return dto.ListEnvironmentsResponse{}, err + } + resp := dto.ListEnvironmentsResponse{ + Environments: make([]dto.EnvironmentDTO, 0, len(environments)), + Total: len(environments), + } + for _, environment := range environments { + resp.Environments = append(resp.Environments, dto.FromEnvironment(environment)) + } + return resp, nil +} + +func (c *EnvironmentController) GetEnvironment(ctx context.Context, req dto.GetEnvironmentRequest) (dto.EnvironmentDTO, error) { + environment, err := c.service.GetEnvironment(ctx, req.ID) + if err != nil { + return dto.EnvironmentDTO{}, err + } + return dto.FromEnvironment(environment), nil +} + +func (c *EnvironmentController) CreateEnvironment(ctx context.Context, req dto.CreateEnvironmentRequest) (dto.EnvironmentDTO, error) { + environment, err := c.service.CreateEnvironment(ctx, &service.CreateEnvironmentRequest{ + Name: req.Name, + Kind: req.Kind, + WorktreeRoot: req.WorktreeRoot, + ImageTag: req.ImageTag, + Dockerfile: req.Dockerfile, + BuildConfig: req.BuildConfig, + }) + if err != nil { + return dto.EnvironmentDTO{}, err + } + return dto.FromEnvironment(environment), nil +} + +func (c *EnvironmentController) UpdateEnvironment(ctx context.Context, req dto.UpdateEnvironmentRequest) (dto.EnvironmentDTO, error) { + environment, err := c.service.UpdateEnvironment(ctx, req.ID, &service.UpdateEnvironmentRequest{ + Name: req.Name, + Kind: req.Kind, + WorktreeRoot: req.WorktreeRoot, + ImageTag: req.ImageTag, + Dockerfile: req.Dockerfile, + BuildConfig: req.BuildConfig, + }) + if err != nil { + return dto.EnvironmentDTO{}, err + } + return dto.FromEnvironment(environment), nil +} + +func (c *EnvironmentController) DeleteEnvironment(ctx context.Context, req dto.DeleteEnvironmentRequest) (dto.SuccessResponse, error) { + if err := c.service.DeleteEnvironment(ctx, req.ID); err != nil { + return dto.SuccessResponse{}, err + } + return dto.SuccessResponse{Success: true}, nil +} diff --git a/apps/backend/internal/task/controller/executor.go b/apps/backend/internal/task/controller/executor.go new file mode 100644 index 0000000000..5d4814b2ee --- /dev/null +++ b/apps/backend/internal/task/controller/executor.go @@ -0,0 +1,73 @@ +package controller + +import ( + "context" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/service" +) + +type ExecutorController struct { + service *service.Service +} + +func NewExecutorController(svc *service.Service) *ExecutorController { + return &ExecutorController{service: svc} +} + +func (c *ExecutorController) ListExecutors(ctx context.Context, _ dto.ListExecutorsRequest) (dto.ListExecutorsResponse, error) { + executors, err := c.service.ListExecutors(ctx) + if err != nil { + return dto.ListExecutorsResponse{}, err + } + 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)) + } + return resp, nil +} + +func (c *ExecutorController) GetExecutor(ctx context.Context, req dto.GetExecutorRequest) (dto.ExecutorDTO, error) { + executor, err := c.service.GetExecutor(ctx, req.ID) + if err != nil { + return dto.ExecutorDTO{}, err + } + return dto.FromExecutor(executor), nil +} + +func (c *ExecutorController) CreateExecutor(ctx context.Context, req dto.CreateExecutorRequest) (dto.ExecutorDTO, error) { + executor, err := c.service.CreateExecutor(ctx, &service.CreateExecutorRequest{ + Name: req.Name, + Type: req.Type, + Status: req.Status, + IsSystem: req.IsSystem, + Config: req.Config, + }) + if err != nil { + return dto.ExecutorDTO{}, err + } + return dto.FromExecutor(executor), nil +} + +func (c *ExecutorController) UpdateExecutor(ctx context.Context, req dto.UpdateExecutorRequest) (dto.ExecutorDTO, error) { + executor, err := c.service.UpdateExecutor(ctx, req.ID, &service.UpdateExecutorRequest{ + Name: req.Name, + Type: req.Type, + Status: req.Status, + Config: req.Config, + }) + if err != nil { + return dto.ExecutorDTO{}, err + } + return dto.FromExecutor(executor), nil +} + +func (c *ExecutorController) DeleteExecutor(ctx context.Context, req dto.DeleteExecutorRequest) (dto.SuccessResponse, error) { + if err := c.service.DeleteExecutor(ctx, req.ID); err != nil { + return dto.SuccessResponse{}, err + } + return dto.SuccessResponse{Success: true}, nil +} diff --git a/apps/backend/internal/task/controller/workspace.go b/apps/backend/internal/task/controller/workspace.go index 6ccc7c53ed..0cdf2ac25f 100644 --- a/apps/backend/internal/task/controller/workspace.go +++ b/apps/backend/internal/task/controller/workspace.go @@ -40,9 +40,10 @@ func (c *WorkspaceController) GetWorkspace(ctx context.Context, req dto.GetWorks func (c *WorkspaceController) CreateWorkspace(ctx context.Context, req dto.CreateWorkspaceRequest) (dto.WorkspaceDTO, error) { workspace, err := c.service.CreateWorkspace(ctx, &service.CreateWorkspaceRequest{ - Name: req.Name, - Description: req.Description, - OwnerID: req.OwnerID, + Name: req.Name, + Description: req.Description, + OwnerID: req.OwnerID, + DefaultExecutorID: req.DefaultExecutorID, }) if err != nil { return dto.WorkspaceDTO{}, err @@ -52,8 +53,9 @@ func (c *WorkspaceController) CreateWorkspace(ctx context.Context, req dto.Creat func (c *WorkspaceController) UpdateWorkspace(ctx context.Context, req dto.UpdateWorkspaceRequest) (dto.WorkspaceDTO, error) { workspace, err := c.service.UpdateWorkspace(ctx, req.ID, &service.UpdateWorkspaceRequest{ - Name: req.Name, - Description: req.Description, + Name: req.Name, + Description: req.Description, + DefaultExecutorID: req.DefaultExecutorID, }) if err != nil { return dto.WorkspaceDTO{}, err diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 6f66366388..b41eb00059 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -18,12 +18,13 @@ type BoardDTO struct { } type WorkspaceDTO struct { - ID string `json:"id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - OwnerID string `json:"owner_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + OwnerID string `json:"owner_id"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ColumnDTO struct { @@ -64,6 +65,29 @@ type RepositoryScriptDTO struct { UpdatedAt time.Time `json:"updated_at"` } +type ExecutorDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Type models.ExecutorType `json:"type"` + Status models.ExecutorStatus `json:"status"` + IsSystem bool `json:"is_system"` + Config map[string]string `json:"config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type EnvironmentDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Kind models.EnvironmentKind `json:"kind"` + WorktreeRoot string `json:"worktree_root,omitempty"` + ImageTag string `json:"image_tag,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type TaskDTO struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` @@ -117,6 +141,16 @@ type ListRepositoryScriptsResponse struct { Total int `json:"total"` } +type ListExecutorsResponse struct { + Executors []ExecutorDTO `json:"executors"` + Total int `json:"total"` +} + +type ListEnvironmentsResponse struct { + Environments []EnvironmentDTO `json:"environments"` + Total int `json:"total"` +} + type RepositoryBranchesResponse struct { Branches []string `json:"branches"` Total int `json:"total"` @@ -173,14 +207,19 @@ func FromWorkspace(workspace *models.Workspace) WorkspaceDTO { if workspace.Description != "" { description = &workspace.Description } + var defaultExecutorID *string + if workspace.DefaultExecutorID != "" { + defaultExecutorID = &workspace.DefaultExecutorID + } return WorkspaceDTO{ - ID: workspace.ID, - Name: workspace.Name, - Description: description, - OwnerID: workspace.OwnerID, - CreatedAt: workspace.CreatedAt, - UpdatedAt: workspace.UpdatedAt, + ID: workspace.ID, + Name: workspace.Name, + Description: description, + OwnerID: workspace.OwnerID, + DefaultExecutorID: defaultExecutorID, + CreatedAt: workspace.CreatedAt, + UpdatedAt: workspace.UpdatedAt, } } @@ -228,6 +267,33 @@ func FromRepositoryScript(script *models.RepositoryScript) RepositoryScriptDTO { } } +func FromExecutor(executor *models.Executor) ExecutorDTO { + return ExecutorDTO{ + ID: executor.ID, + Name: executor.Name, + Type: executor.Type, + Status: executor.Status, + IsSystem: executor.IsSystem, + Config: executor.Config, + CreatedAt: executor.CreatedAt, + UpdatedAt: executor.UpdatedAt, + } +} + +func FromEnvironment(environment *models.Environment) EnvironmentDTO { + return EnvironmentDTO{ + ID: environment.ID, + Name: environment.Name, + Kind: environment.Kind, + WorktreeRoot: environment.WorktreeRoot, + ImageTag: environment.ImageTag, + Dockerfile: environment.Dockerfile, + BuildConfig: environment.BuildConfig, + CreatedAt: environment.CreatedAt, + UpdatedAt: environment.UpdatedAt, + } +} + func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO { return LocalRepositoryDTO{ Path: repo.Path, diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 78d651a754..2889f93f33 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -1,6 +1,9 @@ package dto -import v1 "github.com/kandev/kandev/pkg/api/v1" +import ( + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) type ListBoardsRequest struct { WorkspaceID string @@ -13,15 +16,17 @@ type GetWorkspaceRequest struct { } type CreateWorkspaceRequest struct { - Name string - Description string - OwnerID string + Name string + Description string + OwnerID string + DefaultExecutorID string } type UpdateWorkspaceRequest struct { - ID string - Name *string - Description *string + ID string + Name *string + Description *string + DefaultExecutorID *string } type DeleteWorkspaceRequest struct { @@ -120,6 +125,61 @@ type GetRepositoryScriptRequest struct { ID string } +type ListExecutorsRequest struct{} + +type GetExecutorRequest struct { + ID string +} + +type CreateExecutorRequest struct { + Name string + Type models.ExecutorType + Status models.ExecutorStatus + IsSystem bool + Config map[string]string +} + +type UpdateExecutorRequest struct { + ID string + Name *string + Type *models.ExecutorType + Status *models.ExecutorStatus + Config map[string]string +} + +type DeleteExecutorRequest struct { + ID string +} + +type ListEnvironmentsRequest struct{} + +type GetEnvironmentRequest struct { + ID string +} + +type CreateEnvironmentRequest struct { + Name string + Kind models.EnvironmentKind + WorktreeRoot string + ImageTag string + Dockerfile string + BuildConfig map[string]string +} + +type UpdateEnvironmentRequest struct { + ID string + Name *string + Kind *models.EnvironmentKind + WorktreeRoot *string + ImageTag *string + Dockerfile *string + BuildConfig map[string]string +} + +type DeleteEnvironmentRequest struct { + ID string +} + type CreateRepositoryScriptRequest struct { RepositoryID string Name string diff --git a/apps/backend/internal/task/handlers/environment_handlers.go b/apps/backend/internal/task/handlers/environment_handlers.go new file mode 100644 index 0000000000..f9b2b83262 --- /dev/null +++ b/apps/backend/internal/task/handlers/environment_handlers.go @@ -0,0 +1,268 @@ +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/task/controller" + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + ws "github.com/kandev/kandev/pkg/websocket" +) + +type EnvironmentHandlers struct { + controller *controller.EnvironmentController + logger *logger.Logger +} + +func NewEnvironmentHandlers(ctrl *controller.EnvironmentController, log *logger.Logger) *EnvironmentHandlers { + return &EnvironmentHandlers{controller: ctrl, logger: log} +} + +func RegisterEnvironmentRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.EnvironmentController, log *logger.Logger) { + handlers := NewEnvironmentHandlers(ctrl, log) + handlers.registerHTTP(router) + handlers.registerWS(dispatcher) +} + +func (h *EnvironmentHandlers) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.GET("/environments", h.httpListEnvironments) + api.POST("/environments", h.httpCreateEnvironment) + api.GET("/environments/:id", h.httpGetEnvironment) + api.PATCH("/environments/:id", h.httpUpdateEnvironment) + api.DELETE("/environments/:id", h.httpDeleteEnvironment) +} + +func (h *EnvironmentHandlers) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionEnvironmentList, h.wsListEnvironments) + dispatcher.RegisterFunc(ws.ActionEnvironmentCreate, h.wsCreateEnvironment) + dispatcher.RegisterFunc(ws.ActionEnvironmentGet, h.wsGetEnvironment) + dispatcher.RegisterFunc(ws.ActionEnvironmentUpdate, h.wsUpdateEnvironment) + dispatcher.RegisterFunc(ws.ActionEnvironmentDelete, h.wsDeleteEnvironment) +} + +func (h *EnvironmentHandlers) httpListEnvironments(c *gin.Context) { + resp, err := h.controller.ListEnvironments(c.Request.Context(), dto.ListEnvironmentsRequest{}) + if err != nil { + h.logger.Error("failed to list environments", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list environments"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpCreateEnvironmentRequest struct { + Name string `json:"name"` + Kind string `json:"kind"` + WorktreeRoot string `json:"worktree_root,omitempty"` + ImageTag string `json:"image_tag,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` +} + +func (h *EnvironmentHandlers) httpCreateEnvironment(c *gin.Context) { + var body httpCreateEnvironmentRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.Name == "" || body.Kind == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name and kind are required"}) + return + } + resp, err := h.controller.CreateEnvironment(c.Request.Context(), dto.CreateEnvironmentRequest{ + Name: body.Name, + Kind: models.EnvironmentKind(body.Kind), + WorktreeRoot: body.WorktreeRoot, + ImageTag: body.ImageTag, + Dockerfile: body.Dockerfile, + BuildConfig: body.BuildConfig, + }) + if err != nil { + h.logger.Error("failed to create environment", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "environment not created"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *EnvironmentHandlers) httpGetEnvironment(c *gin.Context) { + resp, err := h.controller.GetEnvironment(c.Request.Context(), dto.GetEnvironmentRequest{ID: c.Param("id")}) + if err != nil { + h.logger.Error("failed to get environment", zap.Error(err)) + c.JSON(http.StatusNotFound, gin.H{"error": "environment not found"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpUpdateEnvironmentRequest struct { + Name *string `json:"name,omitempty"` + Kind *string `json:"kind,omitempty"` + WorktreeRoot *string `json:"worktree_root,omitempty"` + ImageTag *string `json:"image_tag,omitempty"` + Dockerfile *string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` +} + +func (h *EnvironmentHandlers) httpUpdateEnvironment(c *gin.Context) { + var body httpUpdateEnvironmentRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + var kind *models.EnvironmentKind + if body.Kind != nil { + value := models.EnvironmentKind(*body.Kind) + kind = &value + } + resp, err := h.controller.UpdateEnvironment(c.Request.Context(), dto.UpdateEnvironmentRequest{ + ID: c.Param("id"), + Name: body.Name, + Kind: kind, + WorktreeRoot: body.WorktreeRoot, + ImageTag: body.ImageTag, + Dockerfile: body.Dockerfile, + BuildConfig: body.BuildConfig, + }) + if err != nil { + h.logger.Error("failed to update environment", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "environment not updated"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *EnvironmentHandlers) httpDeleteEnvironment(c *gin.Context) { + resp, err := h.controller.DeleteEnvironment(c.Request.Context(), dto.DeleteEnvironmentRequest{ID: c.Param("id")}) + if err != nil { + h.logger.Error("failed to delete environment", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": "environment not deleted"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *EnvironmentHandlers) wsListEnvironments(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + resp, err := h.controller.ListEnvironments(ctx, dto.ListEnvironmentsRequest{}) + if err != nil { + h.logger.Error("failed to list environments", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list environments", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsCreateEnvironmentRequest struct { + Name string `json:"name"` + Kind string `json:"kind"` + WorktreeRoot string `json:"worktree_root,omitempty"` + ImageTag string `json:"image_tag,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` +} + +func (h *EnvironmentHandlers) wsCreateEnvironment(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsCreateEnvironmentRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.Name == "" || req.Kind == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name and kind are required", nil) + } + resp, err := h.controller.CreateEnvironment(ctx, dto.CreateEnvironmentRequest{ + Name: req.Name, + Kind: models.EnvironmentKind(req.Kind), + WorktreeRoot: req.WorktreeRoot, + ImageTag: req.ImageTag, + Dockerfile: req.Dockerfile, + BuildConfig: req.BuildConfig, + }) + if err != nil { + h.logger.Error("failed to create environment", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create environment", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsGetEnvironmentRequest struct { + ID string `json:"id"` +} + +func (h *EnvironmentHandlers) wsGetEnvironment(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetEnvironmentRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := h.controller.GetEnvironment(ctx, dto.GetEnvironmentRequest{ID: req.ID}) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Environment not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsUpdateEnvironmentRequest struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Kind *string `json:"kind,omitempty"` + WorktreeRoot *string `json:"worktree_root,omitempty"` + ImageTag *string `json:"image_tag,omitempty"` + Dockerfile *string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` +} + +func (h *EnvironmentHandlers) wsUpdateEnvironment(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateEnvironmentRequest + 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) + } + var kind *models.EnvironmentKind + if req.Kind != nil { + value := models.EnvironmentKind(*req.Kind) + kind = &value + } + resp, err := h.controller.UpdateEnvironment(ctx, dto.UpdateEnvironmentRequest{ + ID: req.ID, + Name: req.Name, + Kind: kind, + WorktreeRoot: req.WorktreeRoot, + ImageTag: req.ImageTag, + Dockerfile: req.Dockerfile, + BuildConfig: req.BuildConfig, + }) + if err != nil { + h.logger.Error("failed to update environment", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update environment", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsDeleteEnvironmentRequest struct { + ID string `json:"id"` +} + +func (h *EnvironmentHandlers) wsDeleteEnvironment(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsDeleteEnvironmentRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := h.controller.DeleteEnvironment(ctx, dto.DeleteEnvironmentRequest{ID: req.ID}) + if err != nil { + h.logger.Error("failed to delete environment", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete environment", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} diff --git a/apps/backend/internal/task/handlers/executor_handlers.go b/apps/backend/internal/task/handlers/executor_handlers.go new file mode 100644 index 0000000000..079f5f2eac --- /dev/null +++ b/apps/backend/internal/task/handlers/executor_handlers.go @@ -0,0 +1,274 @@ +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/task/controller" + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + ws "github.com/kandev/kandev/pkg/websocket" +) + +type ExecutorHandlers struct { + controller *controller.ExecutorController + logger *logger.Logger +} + +func NewExecutorHandlers(ctrl *controller.ExecutorController, log *logger.Logger) *ExecutorHandlers { + return &ExecutorHandlers{controller: ctrl, logger: log} +} + +func RegisterExecutorRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.ExecutorController, log *logger.Logger) { + handlers := NewExecutorHandlers(ctrl, log) + handlers.registerHTTP(router) + handlers.registerWS(dispatcher) +} + +func (h *ExecutorHandlers) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.GET("/executors", h.httpListExecutors) + api.POST("/executors", h.httpCreateExecutor) + api.GET("/executors/:id", h.httpGetExecutor) + api.PATCH("/executors/:id", h.httpUpdateExecutor) + api.DELETE("/executors/:id", h.httpDeleteExecutor) +} + +func (h *ExecutorHandlers) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionExecutorList, h.wsListExecutors) + dispatcher.RegisterFunc(ws.ActionExecutorCreate, h.wsCreateExecutor) + dispatcher.RegisterFunc(ws.ActionExecutorGet, h.wsGetExecutor) + dispatcher.RegisterFunc(ws.ActionExecutorUpdate, h.wsUpdateExecutor) + dispatcher.RegisterFunc(ws.ActionExecutorDelete, h.wsDeleteExecutor) +} + +func (h *ExecutorHandlers) httpListExecutors(c *gin.Context) { + resp, err := h.controller.ListExecutors(c.Request.Context(), dto.ListExecutorsRequest{}) + if err != nil { + h.logger.Error("failed to list executors", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list executors"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpCreateExecutorRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + IsSystem bool `json:"is_system"` + Config map[string]string `json:"config,omitempty"` +} + +func (h *ExecutorHandlers) httpCreateExecutor(c *gin.Context) { + var body httpCreateExecutorRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.Name == "" || body.Type == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"}) + return + } + status := body.Status + if status == "" { + status = string(models.ExecutorStatusActive) + } + resp, err := h.controller.CreateExecutor(c.Request.Context(), dto.CreateExecutorRequest{ + Name: body.Name, + Type: models.ExecutorType(body.Type), + Status: models.ExecutorStatus(status), + IsSystem: body.IsSystem, + Config: body.Config, + }) + if err != nil { + h.logger.Error("failed to create executor", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "executor not created"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *ExecutorHandlers) httpGetExecutor(c *gin.Context) { + resp, err := h.controller.GetExecutor(c.Request.Context(), dto.GetExecutorRequest{ID: c.Param("id")}) + if err != nil { + h.logger.Error("failed to get executor", zap.Error(err)) + c.JSON(http.StatusNotFound, gin.H{"error": "executor not found"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpUpdateExecutorRequest struct { + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + Status *string `json:"status,omitempty"` + Config map[string]string `json:"config,omitempty"` +} + +func (h *ExecutorHandlers) httpUpdateExecutor(c *gin.Context) { + var body httpUpdateExecutorRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + var execType *models.ExecutorType + if body.Type != nil { + value := models.ExecutorType(*body.Type) + execType = &value + } + var execStatus *models.ExecutorStatus + if body.Status != nil { + value := models.ExecutorStatus(*body.Status) + execStatus = &value + } + resp, err := h.controller.UpdateExecutor(c.Request.Context(), dto.UpdateExecutorRequest{ + ID: c.Param("id"), + Name: body.Name, + Type: execType, + Status: execStatus, + Config: body.Config, + }) + if err != nil { + h.logger.Error("failed to update executor", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "executor not updated"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *ExecutorHandlers) httpDeleteExecutor(c *gin.Context) { + resp, err := h.controller.DeleteExecutor(c.Request.Context(), dto.DeleteExecutorRequest{ID: c.Param("id")}) + if err != nil { + h.logger.Error("failed to delete executor", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": "executor not deleted"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *ExecutorHandlers) wsListExecutors(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + resp, err := h.controller.ListExecutors(ctx, dto.ListExecutorsRequest{}) + if err != nil { + h.logger.Error("failed to list executors", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list executors", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsCreateExecutorRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + IsSystem bool `json:"is_system"` + Config map[string]string `json:"config,omitempty"` +} + +func (h *ExecutorHandlers) wsCreateExecutor(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsCreateExecutorRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.Name == "" || req.Type == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name and type are required", nil) + } + status := req.Status + if status == "" { + status = string(models.ExecutorStatusActive) + } + resp, err := h.controller.CreateExecutor(ctx, dto.CreateExecutorRequest{ + Name: req.Name, + Type: models.ExecutorType(req.Type), + Status: models.ExecutorStatus(status), + IsSystem: req.IsSystem, + Config: req.Config, + }) + if err != nil { + h.logger.Error("failed to create executor", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create executor", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsGetExecutorRequest struct { + ID string `json:"id"` +} + +func (h *ExecutorHandlers) wsGetExecutor(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetExecutorRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := h.controller.GetExecutor(ctx, dto.GetExecutorRequest{ID: req.ID}) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Executor not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsUpdateExecutorRequest struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + Status *string `json:"status,omitempty"` + Config map[string]string `json:"config,omitempty"` +} + +func (h *ExecutorHandlers) wsUpdateExecutor(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateExecutorRequest + 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) + } + var execType *models.ExecutorType + if req.Type != nil { + value := models.ExecutorType(*req.Type) + execType = &value + } + var execStatus *models.ExecutorStatus + if req.Status != nil { + value := models.ExecutorStatus(*req.Status) + execStatus = &value + } + resp, err := h.controller.UpdateExecutor(ctx, dto.UpdateExecutorRequest{ + ID: req.ID, + Name: req.Name, + Type: execType, + Status: execStatus, + Config: req.Config, + }) + if err != nil { + h.logger.Error("failed to update executor", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update executor", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsDeleteExecutorRequest struct { + ID string `json:"id"` +} + +func (h *ExecutorHandlers) wsDeleteExecutor(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsDeleteExecutorRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := h.controller.DeleteExecutor(ctx, dto.DeleteExecutorRequest{ID: req.ID}) + if err != nil { + h.logger.Error("failed to delete executor", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete executor", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} diff --git a/apps/backend/internal/task/handlers/workspace_handlers.go b/apps/backend/internal/task/handlers/workspace_handlers.go index 91a975964d..b2142fa876 100644 --- a/apps/backend/internal/task/handlers/workspace_handlers.go +++ b/apps/backend/internal/task/handlers/workspace_handlers.go @@ -61,9 +61,10 @@ func (h *WorkspaceHandlers) httpListWorkspaces(c *gin.Context) { } type httpCreateWorkspaceRequest struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - OwnerID string `json:"owner_id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + DefaultExecutorID string `json:"default_executor_id,omitempty"` } func (h *WorkspaceHandlers) httpCreateWorkspace(c *gin.Context) { @@ -77,9 +78,10 @@ func (h *WorkspaceHandlers) httpCreateWorkspace(c *gin.Context) { return } resp, err := h.controller.CreateWorkspace(c.Request.Context(), dto.CreateWorkspaceRequest{ - Name: body.Name, - Description: body.Description, - OwnerID: body.OwnerID, + Name: body.Name, + Description: body.Description, + OwnerID: body.OwnerID, + DefaultExecutorID: body.DefaultExecutorID, }) if err != nil { handleNotFound(c, h.logger, err, "workspace not created") @@ -98,8 +100,9 @@ func (h *WorkspaceHandlers) httpGetWorkspace(c *gin.Context) { } type httpUpdateWorkspaceRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` } func (h *WorkspaceHandlers) httpUpdateWorkspace(c *gin.Context) { @@ -109,9 +112,10 @@ func (h *WorkspaceHandlers) httpUpdateWorkspace(c *gin.Context) { return } resp, err := h.controller.UpdateWorkspace(c.Request.Context(), dto.UpdateWorkspaceRequest{ - ID: c.Param("id"), - Name: body.Name, - Description: body.Description, + ID: c.Param("id"), + Name: body.Name, + Description: body.Description, + DefaultExecutorID: body.DefaultExecutorID, }) if err != nil { handleNotFound(c, h.logger, err, "workspace not updated") @@ -141,9 +145,10 @@ func (h *WorkspaceHandlers) wsListWorkspaces(ctx context.Context, msg *ws.Messag } type wsCreateWorkspaceRequest struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - OwnerID string `json:"owner_id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + DefaultExecutorID string `json:"default_executor_id,omitempty"` } func (h *WorkspaceHandlers) wsCreateWorkspace(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -156,9 +161,10 @@ func (h *WorkspaceHandlers) wsCreateWorkspace(ctx context.Context, msg *ws.Messa } resp, err := h.controller.CreateWorkspace(ctx, dto.CreateWorkspaceRequest{ - Name: req.Name, - Description: req.Description, - OwnerID: req.OwnerID, + Name: req.Name, + Description: req.Description, + OwnerID: req.OwnerID, + DefaultExecutorID: req.DefaultExecutorID, }) if err != nil { h.logger.Error("failed to create workspace", zap.Error(err)) @@ -188,9 +194,10 @@ func (h *WorkspaceHandlers) wsGetWorkspace(ctx context.Context, msg *ws.Message) } type wsUpdateWorkspaceRequest struct { - ID string `json:"id"` - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` } func (h *WorkspaceHandlers) wsUpdateWorkspace(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -203,9 +210,10 @@ func (h *WorkspaceHandlers) wsUpdateWorkspace(ctx context.Context, msg *ws.Messa } resp, err := h.controller.UpdateWorkspace(ctx, dto.UpdateWorkspaceRequest{ - ID: req.ID, - Name: req.Name, - Description: req.Description, + ID: req.ID, + Name: req.Name, + Description: req.Description, + DefaultExecutorID: req.DefaultExecutorID, }) if err != nil { h.logger.Error("failed to update workspace", zap.Error(err)) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index decc33a9c6..32b4924d3a 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -38,12 +38,13 @@ type Board struct { // Workspace represents a workspace type Workspace struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - OwnerID string `json:"owner_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + DefaultExecutorID string `json:"default_executor_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Column represents a column in a board @@ -151,6 +152,8 @@ type AgentSession struct { ContainerID string `json:"container_id"` // Docker container ID for cleanup AgentType string `json:"agent_type"` // e.g., "augment-agent" ACPSessionID string `json:"acp_session_id"` // ACP protocol session for resumption + ExecutorID string `json:"executor_id"` + EnvironmentID string `json:"environment_id"` Status AgentSessionStatus `json:"status"` Progress int `json:"progress"` // 0-100 ErrorMessage string `json:"error_message,omitempty"` @@ -170,6 +173,8 @@ func (s *AgentSession) ToAPI() map[string]interface{} { "container_id": s.ContainerID, "agent_type": s.AgentType, "acp_session_id": s.ACPSessionID, + "executor_id": s.ExecutorID, + "environment_id": s.EnvironmentID, "status": string(s.Status), "progress": s.Progress, "started_at": s.StartedAt, @@ -219,6 +224,68 @@ type RepositoryScript struct { UpdatedAt time.Time `json:"updated_at"` } +// ExecutorType represents the executor runtime type. +type ExecutorType string + +const ( + ExecutorTypeLocalPC ExecutorType = "local_pc" + ExecutorTypeLocalDocker ExecutorType = "local_docker" + ExecutorTypeRemoteDocker ExecutorType = "remote_docker" + ExecutorTypeRemoteVPS ExecutorType = "remote_vps" + ExecutorTypeK8s ExecutorType = "k8s" +) + +const ( + ExecutorIDLocalPC = "exec-local-pc" + ExecutorIDLocalDocker = "exec-local-docker" + ExecutorIDRemoteDocker = "exec-remote-docker" +) + +// ExecutorStatus represents executor availability. +type ExecutorStatus string + +const ( + ExecutorStatusActive ExecutorStatus = "active" + ExecutorStatusDisabled ExecutorStatus = "disabled" +) + +// Executor represents an execution target. +type Executor struct { + ID string `json:"id"` + Name string `json:"name"` + Type ExecutorType `json:"type"` + Status ExecutorStatus `json:"status"` + IsSystem bool `json:"is_system"` + Config map[string]string `json:"config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// EnvironmentKind represents the runtime type for environments. +type EnvironmentKind string + +const ( + EnvironmentKindLocalPC EnvironmentKind = "local_pc" + EnvironmentKindDockerImage EnvironmentKind = "docker_image" +) + +const ( + EnvironmentIDLocal = "env-local" +) + +// Environment represents a runtime environment configuration. +type Environment struct { + ID string `json:"id"` + Name string `json:"name"` + Kind EnvironmentKind `json:"kind"` + WorktreeRoot string `json:"worktree_root,omitempty"` + ImageTag string `json:"image_tag,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ToAPI converts internal Task to API type func (t *Task) ToAPI() *v1.Task { var agentType *string diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 672aa0b096..569a1d417a 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -73,6 +73,20 @@ type Repository interface { DeleteRepositoryScript(ctx context.Context, id string) error ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) + // Executor operations + CreateExecutor(ctx context.Context, executor *models.Executor) error + GetExecutor(ctx context.Context, id string) (*models.Executor, error) + UpdateExecutor(ctx context.Context, executor *models.Executor) error + DeleteExecutor(ctx context.Context, id string) error + ListExecutors(ctx context.Context) ([]*models.Executor, error) + + // Environment operations + CreateEnvironment(ctx context.Context, environment *models.Environment) error + GetEnvironment(ctx context.Context, id string) (*models.Environment, error) + UpdateEnvironment(ctx context.Context, environment *models.Environment) error + DeleteEnvironment(ctx context.Context, id string) error + ListEnvironments(ctx context.Context) ([]*models.Environment, error) + // Close closes the repository (for database connections) Close() error } diff --git a/apps/backend/internal/task/repository/memory.go b/apps/backend/internal/task/repository/memory.go index 9ab2aa9c3d..1021821e71 100644 --- a/apps/backend/internal/task/repository/memory.go +++ b/apps/backend/internal/task/repository/memory.go @@ -21,6 +21,8 @@ type MemoryRepository struct { repositories map[string]*models.Repository repoScripts map[string]*models.RepositoryScript agentSessions map[string]*models.AgentSession + executors map[string]*models.Executor + environments map[string]*models.Environment taskBoards map[string]map[string]struct{} taskPlacements map[string]map[string]*taskPlacement mu sync.RWMutex @@ -31,7 +33,7 @@ var _ Repository = (*MemoryRepository)(nil) // NewMemoryRepository creates a new in-memory task repository func NewMemoryRepository() *MemoryRepository { - return &MemoryRepository{ + repo := &MemoryRepository{ workspaces: make(map[string]*models.Workspace), tasks: make(map[string]*models.Task), boards: make(map[string]*models.Board), @@ -40,9 +42,55 @@ func NewMemoryRepository() *MemoryRepository { repositories: make(map[string]*models.Repository), repoScripts: make(map[string]*models.RepositoryScript), agentSessions: make(map[string]*models.AgentSession), + executors: make(map[string]*models.Executor), + environments: make(map[string]*models.Environment), taskBoards: make(map[string]map[string]struct{}), taskPlacements: make(map[string]map[string]*taskPlacement), } + repo.seedDefaults() + return repo +} + +func (r *MemoryRepository) seedDefaults() { + now := time.Now().UTC() + r.executors[models.ExecutorIDLocalPC] = &models.Executor{ + ID: models.ExecutorIDLocalPC, + Name: "Local PC", + Type: models.ExecutorTypeLocalPC, + Status: models.ExecutorStatusActive, + IsSystem: true, + Config: map[string]string{}, + CreatedAt: now, + UpdatedAt: now, + } + r.executors[models.ExecutorIDLocalDocker] = &models.Executor{ + ID: models.ExecutorIDLocalDocker, + Name: "Local Docker", + Type: models.ExecutorTypeLocalDocker, + Status: models.ExecutorStatusActive, + IsSystem: false, + Config: map[string]string{"docker_host": "unix:///var/run/docker.sock"}, + CreatedAt: now, + UpdatedAt: now, + } + r.executors[models.ExecutorIDRemoteDocker] = &models.Executor{ + ID: models.ExecutorIDRemoteDocker, + Name: "Remote Docker", + Type: models.ExecutorTypeRemoteDocker, + Status: models.ExecutorStatusDisabled, + IsSystem: false, + Config: map[string]string{}, + CreatedAt: now, + UpdatedAt: now, + } + r.environments[models.EnvironmentIDLocal] = &models.Environment{ + ID: models.EnvironmentIDLocal, + Name: "Local", + Kind: models.EnvironmentKindLocalPC, + WorktreeRoot: "~/kandev", + CreatedAt: now, + UpdatedAt: now, + } } type taskPlacement struct { @@ -66,6 +114,9 @@ func (r *MemoryRepository) CreateWorkspace(ctx context.Context, workspace *model if workspace.ID == "" { workspace.ID = uuid.New().String() } + if workspace.DefaultExecutorID == "" { + workspace.DefaultExecutorID = models.ExecutorIDLocalPC + } now := time.Now().UTC() workspace.CreatedAt = now workspace.UpdatedAt = now @@ -652,7 +703,6 @@ func (r *MemoryRepository) DeleteComment(ctx context.Context, id string) error { return nil } - // AgentSession operations // CreateAgentSession creates a new agent session @@ -787,4 +837,127 @@ func (r *MemoryRepository) DeleteAgentSession(ctx context.Context, id string) er } delete(r.agentSessions, id) return nil -} \ No newline at end of file +} + +// Executor operations + +func (r *MemoryRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { + r.mu.Lock() + defer r.mu.Unlock() + + if executor.ID == "" { + executor.ID = uuid.New().String() + } + now := time.Now().UTC() + executor.CreatedAt = now + executor.UpdatedAt = now + + r.executors[executor.ID] = executor + return nil +} + +func (r *MemoryRepository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + executor, ok := r.executors[id] + if !ok { + return nil, fmt.Errorf("executor not found: %s", id) + } + return executor, nil +} + +func (r *MemoryRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.executors[executor.ID]; !ok { + return fmt.Errorf("executor not found: %s", executor.ID) + } + executor.UpdatedAt = time.Now().UTC() + r.executors[executor.ID] = executor + return nil +} + +func (r *MemoryRepository) DeleteExecutor(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.executors[id]; !ok { + return fmt.Errorf("executor not found: %s", id) + } + delete(r.executors, id) + return nil +} + +func (r *MemoryRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Executor, 0, len(r.executors)) + for _, executor := range r.executors { + result = append(result, executor) + } + return result, nil +} + +// Environment operations + +func (r *MemoryRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { + r.mu.Lock() + defer r.mu.Unlock() + + if environment.ID == "" { + environment.ID = uuid.New().String() + } + now := time.Now().UTC() + environment.CreatedAt = now + environment.UpdatedAt = now + r.environments[environment.ID] = environment + return nil +} + +func (r *MemoryRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + environment, ok := r.environments[id] + if !ok { + return nil, fmt.Errorf("environment not found: %s", id) + } + return environment, nil +} + +func (r *MemoryRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.environments[environment.ID]; !ok { + return fmt.Errorf("environment not found: %s", environment.ID) + } + environment.UpdatedAt = time.Now().UTC() + r.environments[environment.ID] = environment + return nil +} + +func (r *MemoryRepository) DeleteEnvironment(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.environments[id]; !ok { + return fmt.Errorf("environment not found: %s", id) + } + delete(r.environments, id) + return nil +} + +func (r *MemoryRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Environment, 0, len(r.environments)) + for _, environment := range r.environments { + result = append(result, environment) + } + return result, nil +} diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index 218b0644bd..4c3f7685de 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -89,6 +89,30 @@ func (r *SQLiteRepository) initSchema() error { name TEXT NOT NULL, description TEXT DEFAULT '', owner_id TEXT DEFAULT '', + default_executor_id TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS executors ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + is_system INTEGER NOT NULL DEFAULT 0, + config TEXT DEFAULT '{}', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS environments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + worktree_root TEXT DEFAULT '', + image_tag TEXT DEFAULT '', + dockerfile TEXT DEFAULT '', + build_config TEXT DEFAULT '{}', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); @@ -217,6 +241,8 @@ func (r *SQLiteRepository) initSchema() error { container_id TEXT NOT NULL DEFAULT '', agent_type TEXT NOT NULL, acp_session_id TEXT DEFAULT '', + executor_id TEXT DEFAULT '', + environment_id TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', progress INTEGER DEFAULT 0, error_message TEXT DEFAULT '', @@ -242,6 +268,9 @@ func (r *SQLiteRepository) initSchema() error { if err := r.ensureColumn("tasks", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } + if err := r.ensureColumn("workspaces", "default_executor_id", "TEXT DEFAULT ''"); err != nil { + return err + } if err := r.ensureColumn("columns", "color", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } @@ -261,11 +290,21 @@ func (r *SQLiteRepository) initSchema() error { if err := r.ensureColumn("agent_sessions", "container_id", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } + if err := r.ensureColumn("agent_sessions", "executor_id", "TEXT DEFAULT ''"); err != nil { + return err + } + if err := r.ensureColumn("agent_sessions", "environment_id", "TEXT DEFAULT ''"); err != nil { + return err + } if err := r.ensureDefaultWorkspace(); err != nil { return err } + if err := r.ensureDefaultExecutorsAndEnvironments(); err != nil { + return err + } + if err := r.backfillTaskMappings(); err != nil { return err } @@ -338,9 +377,9 @@ func (r *SQLiteRepository) ensureDefaultWorkspace() error { workspaceDescription = "" } if _, err := r.db.ExecContext(ctx, ` - INSERT INTO workspaces (id, name, description, owner_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - `, defaultID, workspaceName, workspaceDescription, "", now, now); err != nil { + INSERT INTO workspaces (id, name, description, owner_id, default_executor_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, defaultID, workspaceName, workspaceDescription, "", models.ExecutorIDLocalPC, now, now); err != nil { return err } @@ -400,6 +439,98 @@ func (r *SQLiteRepository) ensureDefaultWorkspace() error { return nil } +func (r *SQLiteRepository) ensureDefaultExecutorsAndEnvironments() error { + ctx := context.Background() + + var executorCount int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM executors").Scan(&executorCount); err != nil { + return err + } + + if executorCount == 0 { + now := time.Now().UTC() + executors := []struct { + id string + name string + execType models.ExecutorType + status models.ExecutorStatus + isSystem bool + config map[string]string + }{ + { + id: models.ExecutorIDLocalPC, + name: "Local PC", + execType: models.ExecutorTypeLocalPC, + status: models.ExecutorStatusActive, + isSystem: true, + config: map[string]string{}, + }, + { + id: models.ExecutorIDLocalDocker, + name: "Local Docker", + execType: models.ExecutorTypeLocalDocker, + status: models.ExecutorStatusActive, + isSystem: false, + config: map[string]string{ + "docker_host": "unix:///var/run/docker.sock", + }, + }, + { + id: models.ExecutorIDRemoteDocker, + name: "Remote Docker", + execType: models.ExecutorTypeRemoteDocker, + status: models.ExecutorStatusDisabled, + isSystem: false, + config: map[string]string{}, + }, + } + + for _, executor := range executors { + configJSON, err := json.Marshal(executor.config) + if err != nil { + return fmt.Errorf("failed to serialize executor config: %w", err) + } + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO executors (id, name, type, status, is_system, config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, executor.id, executor.name, executor.execType, executor.status, boolToInt(executor.isSystem), string(configJSON), now, now); err != nil { + return err + } + } + } + + if _, err := r.db.ExecContext(ctx, ` + UPDATE workspaces + SET default_executor_id = ? + WHERE default_executor_id = '' OR default_executor_id IS NULL + `, models.ExecutorIDLocalPC); err != nil { + return err + } + + var envCount int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM environments").Scan(&envCount); err != nil { + return err + } + if envCount == 0 { + now := time.Now().UTC() + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO environments (id, name, kind, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, "~/kandev", "", "", "{}", now, now); err != nil { + return err + } + } + + return nil +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + func (r *SQLiteRepository) ensureWorkspaceIndexes() error { if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`); err != nil { return err @@ -447,14 +578,17 @@ func (r *SQLiteRepository) CreateWorkspace(ctx context.Context, workspace *model if workspace.ID == "" { workspace.ID = uuid.New().String() } + if workspace.DefaultExecutorID == "" { + workspace.DefaultExecutorID = models.ExecutorIDLocalPC + } now := time.Now().UTC() workspace.CreatedAt = now workspace.UpdatedAt = now _, err := r.db.ExecContext(ctx, ` - INSERT INTO workspaces (id, name, description, owner_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - `, workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.CreatedAt, workspace.UpdatedAt) + INSERT INTO workspaces (id, name, description, owner_id, default_executor_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.CreatedAt, workspace.UpdatedAt) return err } @@ -464,9 +598,9 @@ func (r *SQLiteRepository) GetWorkspace(ctx context.Context, id string) (*models workspace := &models.Workspace{} err := r.db.QueryRowContext(ctx, ` - SELECT id, name, description, owner_id, created_at, updated_at + SELECT id, name, description, owner_id, default_executor_id, created_at, updated_at FROM workspaces WHERE id = ? - `, id).Scan(&workspace.ID, &workspace.Name, &workspace.Description, &workspace.OwnerID, &workspace.CreatedAt, &workspace.UpdatedAt) + `, id).Scan(&workspace.ID, &workspace.Name, &workspace.Description, &workspace.OwnerID, &workspace.DefaultExecutorID, &workspace.CreatedAt, &workspace.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("workspace not found: %s", id) @@ -479,8 +613,8 @@ func (r *SQLiteRepository) UpdateWorkspace(ctx context.Context, workspace *model workspace.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, ` - UPDATE workspaces SET name = ?, description = ?, updated_at = ? WHERE id = ? - `, workspace.Name, workspace.Description, workspace.UpdatedAt, workspace.ID) + UPDATE workspaces SET name = ?, description = ?, default_executor_id = ?, updated_at = ? WHERE id = ? + `, workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.UpdatedAt, workspace.ID) if err != nil { return err } @@ -509,7 +643,7 @@ func (r *SQLiteRepository) DeleteWorkspace(ctx context.Context, id string) error // ListWorkspaces returns all workspaces func (r *SQLiteRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, description, owner_id, created_at, updated_at FROM workspaces ORDER BY created_at DESC + SELECT id, name, description, owner_id, default_executor_id, created_at, updated_at FROM workspaces ORDER BY created_at DESC `) if err != nil { return nil, err @@ -519,7 +653,7 @@ func (r *SQLiteRepository) ListWorkspaces(ctx context.Context) ([]*models.Worksp var result []*models.Workspace for rows.Next() { workspace := &models.Workspace{} - if err := rows.Scan(&workspace.ID, &workspace.Name, &workspace.Description, &workspace.OwnerID, &workspace.CreatedAt, &workspace.UpdatedAt); err != nil { + if err := rows.Scan(&workspace.ID, &workspace.Name, &workspace.Description, &workspace.OwnerID, &workspace.DefaultExecutorID, &workspace.CreatedAt, &workspace.UpdatedAt); err != nil { return nil, err } result = append(result, workspace) @@ -1308,7 +1442,6 @@ func (r *SQLiteRepository) ListRepositoryScripts(ctx context.Context, repository return result, rows.Err() } - // Agent Session operations // CreateAgentSession creates a new agent session @@ -1327,11 +1460,11 @@ func (r *SQLiteRepository) CreateAgentSession(ctx context.Context, session *mode _, err = r.db.ExecContext(ctx, ` INSERT INTO agent_sessions ( - id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, status, progress, - error_message, metadata, started_at, completed_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, executor_id, environment_id, + status, progress, error_message, metadata, started_at, completed_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, session.ID, session.TaskID, session.AgentInstanceID, session.ContainerID, session.AgentType, session.ACPSessionID, - string(session.Status), session.Progress, session.ErrorMessage, string(metadataJSON), + session.ExecutorID, session.EnvironmentID, string(session.Status), session.Progress, session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt) return err @@ -1345,12 +1478,12 @@ func (r *SQLiteRepository) GetAgentSession(ctx context.Context, id string) (*mod var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, status, progress, - error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, executor_id, environment_id, + status, progress, error_message, metadata, started_at, completed_at, updated_at FROM agent_sessions WHERE id = ? `, id).Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentType, - &session.ACPSessionID, &status, &session.Progress, &session.ErrorMessage, + &session.ACPSessionID, &session.ExecutorID, &session.EnvironmentID, &status, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) @@ -1382,12 +1515,12 @@ func (r *SQLiteRepository) GetAgentSessionByTaskID(ctx context.Context, taskID s var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, status, progress, - error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, executor_id, environment_id, + status, progress, error_message, metadata, started_at, completed_at, updated_at FROM agent_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1 `, taskID).Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentType, - &session.ACPSessionID, &status, &session.Progress, &session.ErrorMessage, + &session.ACPSessionID, &session.ExecutorID, &session.EnvironmentID, &status, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) @@ -1419,14 +1552,14 @@ func (r *SQLiteRepository) GetActiveAgentSessionByTaskID(ctx context.Context, ta var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, status, progress, - error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, executor_id, environment_id, + status, progress, error_message, metadata, started_at, completed_at, updated_at FROM agent_sessions WHERE task_id = ? AND status IN ('pending', 'running', 'waiting') ORDER BY started_at DESC LIMIT 1 `, taskID).Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentType, - &session.ACPSessionID, &status, &session.Progress, &session.ErrorMessage, + &session.ACPSessionID, &session.ExecutorID, &session.EnvironmentID, &status, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) @@ -1450,7 +1583,6 @@ func (r *SQLiteRepository) GetActiveAgentSessionByTaskID(ctx context.Context, ta return session, nil } - // UpdateAgentSession updates an existing agent session func (r *SQLiteRepository) UpdateAgentSession(ctx context.Context, session *models.AgentSession) error { session.UpdatedAt = time.Now().UTC() @@ -1462,11 +1594,11 @@ func (r *SQLiteRepository) UpdateAgentSession(ctx context.Context, session *mode result, err := r.db.ExecContext(ctx, ` UPDATE agent_sessions SET - agent_instance_id = ?, container_id = ?, agent_type = ?, acp_session_id = ?, status = ?, progress = ?, - error_message = ?, metadata = ?, completed_at = ?, updated_at = ? + agent_instance_id = ?, container_id = ?, agent_type = ?, acp_session_id = ?, executor_id = ?, environment_id = ?, + status = ?, progress = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ? WHERE id = ? - `, session.AgentInstanceID, session.ContainerID, session.AgentType, session.ACPSessionID, string(session.Status), - session.Progress, session.ErrorMessage, string(metadataJSON), session.CompletedAt, + `, session.AgentInstanceID, session.ContainerID, session.AgentType, session.ACPSessionID, session.ExecutorID, session.EnvironmentID, + string(session.Status), session.Progress, session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt, session.ID) if err != nil { return err @@ -1505,8 +1637,8 @@ func (r *SQLiteRepository) UpdateAgentSessionStatus(ctx context.Context, id stri // ListAgentSessions returns all agent sessions for a task func (r *SQLiteRepository) ListAgentSessions(ctx context.Context, taskID string) ([]*models.AgentSession, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, status, progress, - error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, executor_id, environment_id, + status, progress, error_message, metadata, started_at, completed_at, updated_at FROM agent_sessions WHERE task_id = ? ORDER BY started_at DESC `, taskID) if err != nil { @@ -1520,8 +1652,8 @@ func (r *SQLiteRepository) ListAgentSessions(ctx context.Context, taskID string) // ListActiveAgentSessions returns all active agent sessions across all tasks func (r *SQLiteRepository) ListActiveAgentSessions(ctx context.Context) ([]*models.AgentSession, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, status, progress, - error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_type, acp_session_id, executor_id, environment_id, + status, progress, error_message, metadata, started_at, completed_at, updated_at FROM agent_sessions WHERE status IN ('pending', 'running', 'waiting') ORDER BY started_at DESC `) if err != nil { @@ -1543,7 +1675,7 @@ func (r *SQLiteRepository) scanAgentSessions(rows *sql.Rows) ([]*models.AgentSes err := rows.Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentType, - &session.ACPSessionID, &status, &session.Progress, &session.ErrorMessage, + &session.ACPSessionID, &session.ExecutorID, &session.EnvironmentID, &status, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) if err != nil { @@ -1577,4 +1709,233 @@ func (r *SQLiteRepository) DeleteAgentSession(ctx context.Context, id string) er return fmt.Errorf("agent session not found: %s", id) } return nil -} \ No newline at end of file +} + +// Executor operations + +func (r *SQLiteRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { + if executor.ID == "" { + executor.ID = uuid.New().String() + } + now := time.Now().UTC() + executor.CreatedAt = now + executor.UpdatedAt = now + + configJSON, err := json.Marshal(executor.Config) + if err != nil { + return fmt.Errorf("failed to serialize executor config: %w", err) + } + + _, err = r.db.ExecContext(ctx, ` + INSERT INTO executors (id, name, type, status, is_system, config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, executor.ID, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), string(configJSON), executor.CreatedAt, executor.UpdatedAt) + return err +} + +func (r *SQLiteRepository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { + executor := &models.Executor{} + var configJSON string + var isSystem int + + err := r.db.QueryRowContext(ctx, ` + SELECT id, name, type, status, is_system, config, created_at, updated_at + FROM executors WHERE id = ? + `, id).Scan( + &executor.ID, &executor.Name, &executor.Type, &executor.Status, + &isSystem, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("executor not found: %s", id) + } + if err != nil { + return nil, err + } + + executor.IsSystem = isSystem == 1 + if configJSON != "" && configJSON != "{}" { + if err := json.Unmarshal([]byte(configJSON), &executor.Config); err != nil { + return nil, fmt.Errorf("failed to deserialize executor config: %w", err) + } + } + return executor, nil +} + +func (r *SQLiteRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error { + executor.UpdatedAt = time.Now().UTC() + + configJSON, err := json.Marshal(executor.Config) + if err != nil { + return fmt.Errorf("failed to serialize executor config: %w", err) + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, config = ?, updated_at = ? + WHERE id = ? + `, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), string(configJSON), executor.UpdatedAt, executor.ID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("executor not found: %s", executor.ID) + } + return nil +} + +func (r *SQLiteRepository) DeleteExecutor(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM executors WHERE id = ?`, id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("executor not found: %s", id) + } + return nil +} + +func (r *SQLiteRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, type, status, is_system, config, created_at, updated_at + FROM executors ORDER BY created_at ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []*models.Executor + for rows.Next() { + executor := &models.Executor{} + var configJSON string + var isSystem int + if err := rows.Scan( + &executor.ID, &executor.Name, &executor.Type, &executor.Status, + &isSystem, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, + ); err != nil { + return nil, err + } + executor.IsSystem = isSystem == 1 + if configJSON != "" && configJSON != "{}" { + if err := json.Unmarshal([]byte(configJSON), &executor.Config); err != nil { + return nil, fmt.Errorf("failed to deserialize executor config: %w", err) + } + } + result = append(result, executor) + } + return result, rows.Err() +} + +// Environment operations + +func (r *SQLiteRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { + if environment.ID == "" { + environment.ID = uuid.New().String() + } + now := time.Now().UTC() + environment.CreatedAt = now + environment.UpdatedAt = now + + buildConfigJSON, err := json.Marshal(environment.BuildConfig) + if err != nil { + return fmt.Errorf("failed to serialize environment build config: %w", err) + } + + _, err = r.db.ExecContext(ctx, ` + INSERT INTO environments (id, name, kind, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, environment.ID, environment.Name, environment.Kind, environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt) + return err +} + +func (r *SQLiteRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { + environment := &models.Environment{} + var buildConfigJSON string + + err := r.db.QueryRowContext(ctx, ` + SELECT id, name, kind, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at + FROM environments WHERE id = ? + `, id).Scan( + &environment.ID, &environment.Name, &environment.Kind, &environment.WorktreeRoot, + &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, + &environment.CreatedAt, &environment.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("environment not found: %s", id) + } + if err != nil { + return nil, err + } + if buildConfigJSON != "" && buildConfigJSON != "{}" { + if err := json.Unmarshal([]byte(buildConfigJSON), &environment.BuildConfig); err != nil { + return nil, fmt.Errorf("failed to deserialize environment build config: %w", err) + } + } + return environment, nil +} + +func (r *SQLiteRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error { + environment.UpdatedAt = time.Now().UTC() + + buildConfigJSON, err := json.Marshal(environment.BuildConfig) + if err != nil { + return fmt.Errorf("failed to serialize environment build config: %w", err) + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE environments SET name = ?, kind = ?, worktree_root = ?, image_tag = ?, dockerfile = ?, build_config = ?, updated_at = ? + WHERE id = ? + `, environment.Name, environment.Kind, environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("environment not found: %s", environment.ID) + } + return nil +} + +func (r *SQLiteRepository) DeleteEnvironment(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM environments WHERE id = ?`, id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("environment not found: %s", id) + } + return nil +} + +func (r *SQLiteRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, kind, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at + FROM environments ORDER BY created_at ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []*models.Environment + for rows.Next() { + environment := &models.Environment{} + var buildConfigJSON string + if err := rows.Scan( + &environment.ID, &environment.Name, &environment.Kind, &environment.WorktreeRoot, + &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, + &environment.CreatedAt, &environment.UpdatedAt, + ); err != nil { + return nil, err + } + if buildConfigJSON != "" && buildConfigJSON != "{}" { + if err := json.Unmarshal([]byte(buildConfigJSON), &environment.BuildConfig); err != nil { + return nil, fmt.Errorf("failed to deserialize environment build config: %w", err) + } + } + result = append(result, environment) + } + return result, rows.Err() +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 0182a65faa..6eca0d803e 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "time" "github.com/google/uuid" @@ -23,11 +24,11 @@ type WorktreeCleanup interface { // Service provides task business logic type Service struct { - repo repository.Repository - eventBus bus.EventBus - logger *logger.Logger - discoveryConfig RepositoryDiscoveryConfig - worktreeCleanup WorktreeCleanup + repo repository.Repository + eventBus bus.EventBus + logger *logger.Logger + discoveryConfig RepositoryDiscoveryConfig + worktreeCleanup WorktreeCleanup } // NewService creates a new task service @@ -91,15 +92,17 @@ type UpdateBoardRequest struct { // CreateWorkspaceRequest contains the data for creating a new workspace type CreateWorkspaceRequest struct { - Name string `json:"name"` - Description string `json:"description"` - OwnerID string `json:"owner_id"` + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + DefaultExecutorID string `json:"default_executor_id"` } // UpdateWorkspaceRequest contains the data for updating a workspace type UpdateWorkspaceRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` } // CreateColumnRequest contains the data for creating a new column @@ -148,6 +151,43 @@ type UpdateRepositoryRequest struct { CleanupScript *string `json:"cleanup_script,omitempty"` } +// CreateExecutorRequest contains the data for creating an executor +type CreateExecutorRequest struct { + Name string `json:"name"` + Type models.ExecutorType `json:"type"` + Status models.ExecutorStatus `json:"status"` + IsSystem bool `json:"is_system"` + Config map[string]string `json:"config,omitempty"` +} + +// UpdateExecutorRequest contains the data for updating an executor +type UpdateExecutorRequest struct { + Name *string `json:"name,omitempty"` + Type *models.ExecutorType `json:"type,omitempty"` + Status *models.ExecutorStatus `json:"status,omitempty"` + Config map[string]string `json:"config,omitempty"` +} + +// CreateEnvironmentRequest contains the data for creating an environment +type CreateEnvironmentRequest struct { + Name string `json:"name"` + Kind models.EnvironmentKind `json:"kind"` + WorktreeRoot string `json:"worktree_root,omitempty"` + ImageTag string `json:"image_tag,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` +} + +// UpdateEnvironmentRequest contains the data for updating an environment +type UpdateEnvironmentRequest struct { + Name *string `json:"name,omitempty"` + Kind *models.EnvironmentKind `json:"kind,omitempty"` + WorktreeRoot *string `json:"worktree_root,omitempty"` + ImageTag *string `json:"image_tag,omitempty"` + Dockerfile *string `json:"dockerfile,omitempty"` + BuildConfig map[string]string `json:"build_config,omitempty"` +} + // CreateRepositoryScriptRequest contains the data for creating a repository script type CreateRepositoryScriptRequest struct { RepositoryID string `json:"repository_id"` @@ -420,11 +460,16 @@ func (s *Service) MoveTask(ctx context.Context, id string, boardID string, colum // CreateWorkspace creates a new workspace func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceRequest) (*models.Workspace, error) { + defaultExecutorID := req.DefaultExecutorID + if defaultExecutorID == "" { + defaultExecutorID = models.ExecutorIDLocalPC + } workspace := &models.Workspace{ - ID: uuid.New().String(), - Name: req.Name, - Description: req.Description, - OwnerID: req.OwnerID, + ID: uuid.New().String(), + Name: req.Name, + Description: req.Description, + OwnerID: req.OwnerID, + DefaultExecutorID: defaultExecutorID, } if err := s.repo.CreateWorkspace(ctx, workspace); err != nil { @@ -455,6 +500,9 @@ func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWor if req.Description != nil { workspace.Description = *req.Description } + if req.DefaultExecutorID != nil { + workspace.DefaultExecutorID = *req.DefaultExecutorID + } workspace.UpdatedAt = time.Now().UTC() if err := s.repo.UpdateWorkspace(ctx, workspace); err != nil { @@ -815,6 +863,154 @@ func (s *Service) ListRepositoryScripts(ctx context.Context, repositoryID string return s.repo.ListRepositoryScripts(ctx, repositoryID) } +// Executor operations + +func (s *Service) CreateExecutor(ctx context.Context, req *CreateExecutorRequest) (*models.Executor, error) { + executor := &models.Executor{ + ID: uuid.New().String(), + Name: req.Name, + Type: req.Type, + Status: req.Status, + IsSystem: req.IsSystem, + Config: req.Config, + } + + if err := s.repo.CreateExecutor(ctx, executor); err != nil { + return nil, err + } + s.publishExecutorEvent(ctx, events.ExecutorCreated, executor) + return executor, nil +} + +func (s *Service) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { + return s.repo.GetExecutor(ctx, id) +} + +func (s *Service) UpdateExecutor(ctx context.Context, id string, req *UpdateExecutorRequest) (*models.Executor, error) { + executor, err := s.repo.GetExecutor(ctx, id) + if err != nil { + return nil, err + } + if executor.IsSystem { + return nil, fmt.Errorf("system executors cannot be modified") + } + if req.Name != nil { + executor.Name = *req.Name + } + if req.Type != nil { + executor.Type = *req.Type + } + if req.Status != nil { + executor.Status = *req.Status + } + if req.Config != nil { + executor.Config = req.Config + } + executor.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateExecutor(ctx, executor); err != nil { + return nil, err + } + s.publishExecutorEvent(ctx, events.ExecutorUpdated, executor) + return executor, nil +} + +func (s *Service) DeleteExecutor(ctx context.Context, id string) error { + executor, err := s.repo.GetExecutor(ctx, id) + if err != nil { + return err + } + if executor.IsSystem { + return fmt.Errorf("system executors cannot be deleted") + } + if err := s.repo.DeleteExecutor(ctx, id); err != nil { + return err + } + s.publishExecutorEvent(ctx, events.ExecutorDeleted, executor) + return nil +} + +func (s *Service) ListExecutors(ctx context.Context) ([]*models.Executor, error) { + return s.repo.ListExecutors(ctx) +} + +// Environment operations + +func (s *Service) CreateEnvironment(ctx context.Context, req *CreateEnvironmentRequest) (*models.Environment, error) { + environment := &models.Environment{ + ID: uuid.New().String(), + Name: req.Name, + Kind: req.Kind, + WorktreeRoot: req.WorktreeRoot, + ImageTag: req.ImageTag, + Dockerfile: req.Dockerfile, + BuildConfig: req.BuildConfig, + } + if err := s.repo.CreateEnvironment(ctx, environment); err != nil { + return nil, err + } + s.publishEnvironmentEvent(ctx, events.EnvironmentCreated, environment) + return environment, nil +} + +func (s *Service) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { + return s.repo.GetEnvironment(ctx, id) +} + +func (s *Service) UpdateEnvironment(ctx context.Context, id string, req *UpdateEnvironmentRequest) (*models.Environment, error) { + environment, err := s.repo.GetEnvironment(ctx, id) + if err != nil { + return nil, err + } + if environment.ID == models.EnvironmentIDLocal { + if req.Name != nil || req.Kind != nil || req.WorktreeRoot != nil { + return nil, fmt.Errorf("local environment cannot be modified") + } + } + if req.Name != nil { + environment.Name = *req.Name + } + if req.Kind != nil { + environment.Kind = *req.Kind + } + if req.WorktreeRoot != nil { + environment.WorktreeRoot = *req.WorktreeRoot + } + if req.ImageTag != nil { + environment.ImageTag = *req.ImageTag + } + if req.Dockerfile != nil { + environment.Dockerfile = *req.Dockerfile + } + if req.BuildConfig != nil { + environment.BuildConfig = req.BuildConfig + } + environment.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateEnvironment(ctx, environment); err != nil { + return nil, err + } + s.publishEnvironmentEvent(ctx, events.EnvironmentUpdated, environment) + return environment, nil +} + +func (s *Service) DeleteEnvironment(ctx context.Context, id string) error { + if id == models.EnvironmentIDLocal { + return fmt.Errorf("local environment cannot be deleted") + } + environment, err := s.repo.GetEnvironment(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteEnvironment(ctx, id); err != nil { + return err + } + s.publishEnvironmentEvent(ctx, events.EnvironmentDeleted, environment) + return nil +} + +func (s *Service) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { + return s.repo.ListEnvironments(ctx) +} + // publishTaskEvent publishes task events to the event bus func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) { if s.eventBus == nil { @@ -938,6 +1134,57 @@ func (s *Service) publishColumnEvent(ctx context.Context, eventType string, colu } } +func (s *Service) publishExecutorEvent(ctx context.Context, eventType string, executor *models.Executor) { + if s.eventBus == nil || executor == nil { + return + } + + data := map[string]interface{}{ + "id": executor.ID, + "name": executor.Name, + "type": executor.Type, + "status": executor.Status, + "is_system": executor.IsSystem, + "config": executor.Config, + "created_at": executor.CreatedAt.Format(time.RFC3339), + "updated_at": executor.UpdatedAt.Format(time.RFC3339), + } + + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish executor event", + zap.String("event_type", eventType), + zap.String("executor_id", executor.ID), + zap.Error(err)) + } +} + +func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, environment *models.Environment) { + if s.eventBus == nil || environment == nil { + return + } + + data := map[string]interface{}{ + "id": environment.ID, + "name": environment.Name, + "kind": environment.Kind, + "worktree_root": environment.WorktreeRoot, + "image_tag": environment.ImageTag, + "dockerfile": environment.Dockerfile, + "build_config": environment.BuildConfig, + "created_at": environment.CreatedAt.Format(time.RFC3339), + "updated_at": environment.UpdatedAt.Format(time.RFC3339), + } + + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish environment event", + zap.String("event_type", eventType), + zap.String("environment_id", environment.ID), + zap.Error(err)) + } +} + // Comment operations // CreateCommentRequest contains the data for creating a new comment diff --git a/apps/backend/pkg/api/v1/workspace.go b/apps/backend/pkg/api/v1/workspace.go index 6329f53f72..9e005f7ae5 100644 --- a/apps/backend/pkg/api/v1/workspace.go +++ b/apps/backend/pkg/api/v1/workspace.go @@ -4,22 +4,25 @@ import "time" // Workspace represents a workspace type Workspace struct { - ID string `json:"id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - OwnerID string `json:"owner_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + OwnerID string `json:"owner_id"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CreateWorkspaceRequest for creating a new workspace type CreateWorkspaceRequest struct { - Name string `json:"name" binding:"required,max=255"` - Description *string `json:"description,omitempty"` + Name string `json:"name" binding:"required,max=255"` + Description *string `json:"description,omitempty"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` } // UpdateWorkspaceRequest for updating an existing workspace type UpdateWorkspaceRequest struct { - Name *string `json:"name,omitempty" binding:"omitempty,max=255"` - Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty" binding:"omitempty,max=255"` + Description *string `json:"description,omitempty"` + DefaultExecutorID *string `json:"default_executor_id,omitempty"` } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index d2365ae814..9904eb9aae 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -40,6 +40,20 @@ const ( ActionRepositoryScriptUpdate = "repository.script.update" ActionRepositoryScriptDelete = "repository.script.delete" + // Executor actions + ActionExecutorList = "executor.list" + ActionExecutorCreate = "executor.create" + ActionExecutorGet = "executor.get" + ActionExecutorUpdate = "executor.update" + ActionExecutorDelete = "executor.delete" + + // Environment actions + ActionEnvironmentList = "environment.list" + ActionEnvironmentCreate = "environment.create" + ActionEnvironmentGet = "environment.get" + ActionEnvironmentUpdate = "environment.update" + ActionEnvironmentDelete = "environment.delete" + // Task actions ActionTaskList = "task.list" ActionTaskCreate = "task.create" @@ -110,6 +124,12 @@ const ( ActionRepositoryScriptCreated = "repository.script.created" ActionRepositoryScriptUpdated = "repository.script.updated" ActionRepositoryScriptDeleted = "repository.script.deleted" + ActionExecutorCreated = "executor.created" + ActionExecutorUpdated = "executor.updated" + ActionExecutorDeleted = "executor.deleted" + ActionEnvironmentCreated = "environment.created" + ActionEnvironmentUpdated = "environment.updated" + ActionEnvironmentDeleted = "environment.deleted" // Permission request actions (agent -> user -> agent) ActionPermissionRequested = "permission.requested" // Agent requesting permission diff --git a/apps/web/app/actions/environments.ts b/apps/web/app/actions/environments.ts new file mode 100644 index 0000000000..23e42a3dd6 --- /dev/null +++ b/apps/web/app/actions/environments.ts @@ -0,0 +1,64 @@ +'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> +): 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/actions/executors.ts b/apps/web/app/actions/executors.ts new file mode 100644 index 0000000000..1d6d496421 --- /dev/null +++ b/apps/web/app/actions/executors.ts @@ -0,0 +1,62 @@ +'use server'; + +import { getBackendConfig } from '@/lib/config'; +import type { Executor, ListExecutorsResponse } 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 listExecutorsAction(): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/executors`); +} + +export async function getExecutorAction(id: string): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/executors/${id}`); +} + +export async function createExecutorAction(payload: { + name: string; + type: string; + status?: string; + config?: Record; +}): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/executors`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function updateExecutorAction( + id: string, + payload: Partial> +): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/executors/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); +} + +export async function deleteExecutorAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/executors/${id}`, { method: 'DELETE' }); +} diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index ebe1e882fb..b3ca39971e 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -48,14 +48,21 @@ export async function getWorkspaceAction(id: string): Promise { return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${id}`); } -export async function createWorkspaceAction(payload: { name: string; description?: string }) { +export async function createWorkspaceAction(payload: { + name: string; + description?: string; + default_executor_id?: string; +}) { return fetchJson(`${apiBaseUrl}/api/v1/workspaces`, { method: 'POST', body: JSON.stringify(payload), }); } -export async function updateWorkspaceAction(id: string, payload: { name?: string; description?: string }) { +export async function updateWorkspaceAction( + id: string, + payload: { name?: string; description?: string; default_executor_id?: string } +) { return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(payload), diff --git a/apps/web/app/settings/environment/[id]/page.tsx b/apps/web/app/settings/environment/[id]/page.tsx index a5d1960f4d..6e8da321c6 100644 --- a/apps/web/app/settings/environment/[id]/page.tsx +++ b/apps/web/app/settings/environment/[id]/page.tsx @@ -1,15 +1,15 @@ 'use client'; -import { use, useState } from 'react'; +import { use, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { IconPlus, IconTrash } from '@tabler/icons-react'; +import { IconCube, 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 { Textarea } from '@kandev/ui/textarea'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kandev/ui/card'; -import { Separator } from '@kandev/ui/separator'; 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, @@ -18,22 +18,62 @@ import { DialogHeader, DialogTitle, } from '@kandev/ui/dialog'; -import { generateUUID } from '@/lib/utils'; -import { SETTINGS_DATA } from '@/lib/settings/dummy-data'; -import type { Environment, EnvironmentType, BaseDocker, AgentType } from '@/lib/settings/types'; +import { getEnvironmentAction, 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'; + +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, setEnvironment] = useState( - SETTINGS_DATA.environments.find((e) => e.id === id) - ); + const [environment, setEnvironment] = useState(null); + const [savedEnvironment, setSavedEnvironment] = useState(null); + const [isLoading, setIsLoading] = useState(true); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(''); - const [newEnvKey, setNewEnvKey] = useState(''); - const [newEnvValue, setNewEnvValue] = useState(''); - const [newSecretKey, setNewSecretKey] = useState(''); - const [newSecretValue, setNewSecretValue] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + getEnvironmentAction(id) + .then((data) => { + setEnvironment(data); + setSavedEnvironment(data); + }) + .catch(() => setEnvironment(null)) + .finally(() => setIsLoading(false)); + }, [id]); + + const isDirty = useMemo(() => { + if (!environment || !savedEnvironment) return false; + return ( + environment.name !== savedEnvironment.name || + environment.kind !== savedEnvironment.kind || + environment.worktree_root !== savedEnvironment.worktree_root || + environment.image_tag !== savedEnvironment.image_tag || + environment.dockerfile !== savedEnvironment.dockerfile || + JSON.stringify(environment.build_config ?? {}) !== JSON.stringify(savedEnvironment.build_config ?? {}) + ); + }, [environment, savedEnvironment]); + + if (isLoading) { + return ( +
+ + +

Loading environment...

+
+
+
+ ); + } if (!environment) { return ( @@ -50,77 +90,61 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id: ); } - const handleAddEnvVar = () => { - if (newEnvKey.trim()) { - setEnvironment({ - ...environment, - envVariables: [ - ...environment.envVariables, - { id: generateUUID(), key: newEnvKey, value: newEnvValue }, - ], - }); - setNewEnvKey(''); - setNewEnvValue(''); - } - }; - - const handleRemoveEnvVar = (id: string) => { - setEnvironment({ - ...environment, - envVariables: environment.envVariables.filter((v) => v.id !== id), - }); - }; - - const handleAddSecret = () => { - if (newSecretKey.trim()) { - setEnvironment({ - ...environment, - secrets: [ - ...environment.secrets, - { id: generateUUID(), key: newSecretKey, value: newSecretValue }, - ], - }); - setNewSecretKey(''); - setNewSecretValue(''); - } - }; - - const handleRemoveSecret = (id: string) => { - setEnvironment({ - ...environment, - secrets: environment.secrets.filter((s) => s.id !== id), - }); - }; - - const handleToggleAgent = (agentId: AgentType) => { - const installed = environment.installedAgents || []; - if (installed.includes(agentId)) { - setEnvironment({ - ...environment, - installedAgents: installed.filter((id) => id !== agentId), - }); - } else { - setEnvironment({ - ...environment, - installedAgents: [...installed, agentId], - }); - } - }; - - const handleDeleteEnvironment = () => { - if (deleteConfirmText === 'delete') { + const handleDeleteEnvironment = async () => { + if (deleteConfirmText !== 'delete') return; + setIsDeleting(true); + try { + const client = getWebSocketClient(); + if (client) { + await client.request('environment.delete', { id: environment.id }); + } else { + await deleteEnvironmentAction(environment.id); + } router.push('/settings/environments'); + } finally { + setIsDeleting(false); setDeleteDialogOpen(false); } }; + const handleSaveEnvironment = async () => { + if (!environment) return; + setIsSaving(true); + try { + const payload = { + name: environment.name, + kind: environment.kind, + worktree_root: environment.worktree_root ?? undefined, + image_tag: environment.image_tag ?? undefined, + dockerfile: environment.dockerfile ?? undefined, + build_config: environment.build_config ?? undefined, + }; + const client = getWebSocketClient(); + const updated = client + ? await client.request('environment.update', { id: environment.id, ...payload }) + : await updateEnvironmentAction(environment.id, payload); + setEnvironment(updated); + setSavedEnvironment(updated); + router.push('/settings/environments'); + } finally { + setIsSaving(false); + } + }; + return (
-
-

{environment.name}

-

- Configure Docker environment and runtime settings -

+
+
+

{environment.name}

+

+ {environment.kind === 'local_pc' + ? 'Uses your local machine and installed agents.' + : 'Runs inside a Docker image on the selected executor.'} +

+
+
@@ -133,235 +157,165 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id: setEnvironment({ ...environment, name: e.target.value })} + disabled={environment.kind === 'local_pc'} /> + {environment.kind === 'local_pc' && ( +

+ The local environment name is fixed for now. +

+ )} - - - Docker Configuration - - -
+ {environment.kind === 'local_pc' ? ( + + + Local Runtime + Worktrees are created under your local root. + +
- - + +
-
- - -
-
-
- -