feat: environments and executors

This commit is contained in:
Carlos Florêncio
2026-01-13 00:25:47 +00:00
parent 3701d9057b
commit f2aea364af
37 changed files with 3558 additions and 641 deletions
+7 -3
View File
@@ -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)")
+14
View File
@@ -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"
@@ -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()
@@ -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)
@@ -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)
@@ -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
}
@@ -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
}
@@ -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
+78 -12
View File
@@ -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,
+67 -7
View File
@@ -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
@@ -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)
}
@@ -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)
}
@@ -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))
+73 -6
View File
@@ -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
@@ -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
}
+176 -3
View File
@@ -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
}
}
// 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
}
+398 -37
View File
@@ -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
}
}
// 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()
}
+261 -14
View File
@@ -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
+13 -10
View File
@@ -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"`
}
+20
View File
@@ -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
+64
View File
@@ -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<T>(url: string, options?: RequestInit): Promise<T> {
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<ListEnvironmentsResponse> {
return fetchJson<ListEnvironmentsResponse>(`${apiBaseUrl}/api/v1/environments`);
}
export async function getEnvironmentAction(id: string): Promise<Environment> {
return fetchJson<Environment>(`${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<string, string>;
}): Promise<Environment> {
return fetchJson<Environment>(`${apiBaseUrl}/api/v1/environments`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateEnvironmentAction(
id: string,
payload: Partial<Pick<Environment, 'name' | 'kind' | 'worktree_root' | 'image_tag' | 'dockerfile' | 'build_config'>>
): Promise<Environment> {
return fetchJson<Environment>(`${apiBaseUrl}/api/v1/environments/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function deleteEnvironmentAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/environments/${id}`, { method: 'DELETE' });
}
+62
View File
@@ -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<T>(url: string, options?: RequestInit): Promise<T> {
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<ListExecutorsResponse> {
return fetchJson<ListExecutorsResponse>(`${apiBaseUrl}/api/v1/executors`);
}
export async function getExecutorAction(id: string): Promise<Executor> {
return fetchJson<Executor>(`${apiBaseUrl}/api/v1/executors/${id}`);
}
export async function createExecutorAction(payload: {
name: string;
type: string;
status?: string;
config?: Record<string, string>;
}): Promise<Executor> {
return fetchJson<Executor>(`${apiBaseUrl}/api/v1/executors`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateExecutorAction(
id: string,
payload: Partial<Pick<Executor, 'name' | 'type' | 'status' | 'config'>>
): Promise<Executor> {
return fetchJson<Executor>(`${apiBaseUrl}/api/v1/executors/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function deleteExecutorAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/executors/${id}`, { method: 'DELETE' });
}
+9 -2
View File
@@ -48,14 +48,21 @@ export async function getWorkspaceAction(id: string): Promise<Workspace> {
return fetchJson<Workspace>(`${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<Workspace>(`${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<Workspace>(`${apiBaseUrl}/api/v1/workspaces/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
+249 -295
View File
@@ -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<BaseDocker, string> = {
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<Environment | undefined>(
SETTINGS_DATA.environments.find((e) => e.id === id)
);
const [environment, setEnvironment] = useState<Environment | null>(null);
const [savedEnvironment, setSavedEnvironment] = useState<Environment | null>(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 (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Loading environment...</p>
</CardContent>
</Card>
</div>
);
}
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>('environment.update', { id: environment.id, ...payload })
: await updateEnvironmentAction(environment.id, payload);
setEnvironment(updated);
setSavedEnvironment(updated);
router.push('/settings/environments');
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">{environment.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure Docker environment and runtime settings
</p>
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h2 className="text-2xl font-bold">{environment.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
{environment.kind === 'local_pc'
? 'Uses your local machine and installed agents.'
: 'Runs inside a Docker image on the selected executor.'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => router.push('/settings/environments')}>
Back to Environments
</Button>
</div>
<Separator />
@@ -133,235 +157,165 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id:
<Input
value={environment.name}
onChange={(e) => setEnvironment({ ...environment, name: e.target.value })}
disabled={environment.kind === 'local_pc'}
/>
{environment.kind === 'local_pc' && (
<p className="text-xs text-muted-foreground mt-2">
The local environment name is fixed for now.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Docker Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{environment.kind === 'local_pc' ? (
<Card>
<CardHeader>
<CardTitle>Local Runtime</CardTitle>
<CardDescription>Worktrees are created under your local root.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>Environment Type</Label>
<Select
value={environment.type}
onValueChange={(value) => setEnvironment({ ...environment, type: value as EnvironmentType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local-docker">Local Docker</SelectItem>
<SelectItem value="remote-docker">Remote Docker</SelectItem>
</SelectContent>
</Select>
<Label>Worktree root</Label>
<Input value={environment.worktree_root ?? '~/kandev'} disabled className="cursor-default" />
</div>
<div className="space-y-2">
<Label>Base Docker Image</Label>
<Select
value={environment.baseDocker}
onValueChange={(value) => setEnvironment({ ...environment, baseDocker: value as BaseDocker })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="universal">Universal</SelectItem>
<SelectItem value="golang">Golang</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Setup Script</Label>
<Textarea
value={environment.setupScript}
onChange={(e) => setEnvironment({ ...environment, setupScript: e.target.value })}
placeholder="#!/bin/bash&#10;apt-get update"
rows={5}
className="font-mono text-sm"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Environment Variables</CardTitle>
<CardDescription>Key-value pairs available in the container</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
{environment.envVariables.map((envVar) => (
<div key={envVar.id} className="flex items-center gap-2">
<Input value={envVar.key} disabled className="flex-1" />
<Input value={envVar.value} disabled className="flex-1" />
<Button
variant="ghost"
onClick={() => handleRemoveEnvVar(envVar.id)}
className="shrink-0"
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconCube className="h-4 w-4" />
Docker Image
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Base image</Label>
<Select
value={environment.build_config?.base_image ?? 'universal'}
onValueChange={(value) =>
setEnvironment({
...environment,
build_config: {
base_image: value,
install_agents: environment.build_config?.install_agents ?? '',
},
})
}
>
<IconTrash className="h-4 w-4 mr-2" />
Delete
</Button>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(BASE_IMAGE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="KEY"
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value)}
className="flex-1"
/>
<Input
placeholder="value"
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
className="flex-1"
/>
<Button onClick={handleAddEnvVar} className="shrink-0">
<IconPlus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Secrets</CardTitle>
<CardDescription>Sensitive values (not shown in plain text)</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
{environment.secrets.map((secret) => (
<div key={secret.id} className="flex items-center gap-2">
<Input value={secret.key} disabled className="flex-1" />
<Input value="••••••••" type="password" disabled className="flex-1" />
<Button
variant="ghost"
onClick={() => handleRemoveSecret(secret.id)}
className="shrink-0"
>
<IconTrash className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="SECRET_KEY"
value={newSecretKey}
onChange={(e) => setNewSecretKey(e.target.value)}
className="flex-1"
/>
<Input
placeholder="secret value"
type="password"
value={newSecretValue}
onChange={(e) => setNewSecretValue(e.target.value)}
className="flex-1"
/>
<Button onClick={handleAddSecret} className="shrink-0">
<IconPlus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Installed Agents</CardTitle>
<CardDescription>Select agents available in this environment</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{SETTINGS_DATA.agents.map((agent) => (
<label
key={agent.id}
className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-accent"
>
<input
type="checkbox"
checked={(environment.installedAgents || []).includes(agent.agent)}
onChange={() => handleToggleAgent(agent.agent)}
className="h-4 w-4"
<div className="space-y-2">
<Label>Image tag</Label>
<Input
value={environment.image_tag ?? ''}
onChange={(event) =>
setEnvironment({ ...environment, image_tag: event.target.value })
}
/>
<div className="flex-1">
<div className="font-medium">{agent.name}</div>
<div className="text-sm text-muted-foreground">{agent.model}</div>
</div>
</label>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<div className="space-y-2">
<Label>Dockerfile</Label>
<Textarea
value={environment.dockerfile ?? ''}
onChange={(event) =>
setEnvironment({ ...environment, dockerfile: event.target.value })
}
rows={10}
className="font-mono text-sm"
/>
</div>
</CardContent>
</Card>
)}
<Separator />
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently delete this environment
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete this environment</p>
<p className="text-sm text-muted-foreground">
Once deleted, all configuration will be permanently removed
</p>
</div>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Environment
</Button>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => router.push('/settings/environments')}
>
Cancel
</Button>
<Button
onClick={handleSaveEnvironment}
disabled={!isDirty || isSaving || environment.kind === 'local_pc'}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Environment</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the environment &quot;
{environment.name}&quot; and all its data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
Please type <span className="font-mono font-bold">delete</span> to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteEnvironment}
disabled={deleteConfirmText !== 'delete'}
>
Delete Environment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{environment.kind !== 'local_pc' && (
<>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>Irreversible actions that permanently delete this environment.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete this environment</p>
<p className="text-sm text-muted-foreground">
Once deleted, all configuration will be permanently removed.
</p>
</div>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Environment
</Button>
</div>
</CardContent>
</Card>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Environment</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the environment &quot;
{environment.name}&quot; and all its data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
Please type <span className="font-mono font-bold">delete</span> to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteEnvironment}
disabled={deleteConfirmText !== 'delete'}
>
{isDeleting ? 'Deleting...' : 'Delete Environment'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
);
}
@@ -0,0 +1,216 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { IconCube } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kandev/ui/card';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Separator } from '@kandev/ui/separator';
import { Textarea } from '@kandev/ui/textarea';
import type { AgentType, BaseDocker } from '@/lib/settings/types';
import { createEnvironmentAction } from '@/app/actions/environments';
import { getWebSocketClient } from '@/lib/ws/connection';
const AGENT_OPTIONS: Array<{ id: AgentType; label: string; description: string }> = [
{ id: 'claude-code', label: 'Claude Code', description: 'Full-featured coding agent.' },
{ id: 'codex', label: 'Codex', description: 'Fast iterative coding agent.' },
{ id: 'auggie', label: 'Augment', description: 'Code review + planning agent.' },
];
const BASE_IMAGE_LABELS: Record<BaseDocker, string> = {
universal: 'Universal (Ubuntu)',
golang: 'Golang',
node: 'Node.js',
python: 'Python',
};
function generateDockerfile(baseImage: BaseDocker, agents: AgentType[]) {
const baseImageMap: Record<BaseDocker, string> = {
universal: 'ubuntu:22.04',
golang: 'golang:1.23-alpine',
node: 'node:20-bullseye',
python: 'python:3.12-slim',
};
const agentList = agents.length ? agents.join(', ') : 'none';
return [
`FROM ${baseImageMap[baseImage]}`,
'',
'RUN apt-get update && apt-get install -y curl git jq && rm -rf /var/lib/apt/lists/*',
'',
`# Install agents: ${agentList}`,
'# TODO: install selected agents here',
'',
'WORKDIR /workspace',
'',
'# Repository will be mounted at runtime',
].join('\n');
}
export default function EnvironmentCreatePage() {
const router = useRouter();
const [name, setName] = useState('Custom Image');
const [baseImage, setBaseImage] = useState<BaseDocker>('universal');
const [imageTag, setImageTag] = useState('kandev/custom:latest');
const [installAgents, setInstallAgents] = useState<AgentType[]>([]);
const [dockerfile, setDockerfile] = useState('');
const [showDockerfileEditor, setShowDockerfileEditor] = useState(false);
const handleToggleAgent = (agentId: AgentType) => {
setInstallAgents((prev) =>
prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]
);
};
const handleBuildImage = () => {
setDockerfile(generateDockerfile(baseImage, installAgents));
setShowDockerfileEditor(true);
};
const [isCreating, setIsCreating] = useState(false);
const handleCreate = async () => {
setIsCreating(true);
try {
const payload = {
name,
kind: 'docker_image',
image_tag: imageTag,
dockerfile,
build_config: {
base_image: baseImage,
install_agents: installAgents.join(','),
},
};
const client = getWebSocketClient();
if (client) {
await client.request('environment.create', payload);
} else {
await createEnvironmentAction(payload);
}
router.push('/settings/environments');
} finally {
setIsCreating(false);
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">Create Custom Environment</h2>
<p className="text-sm text-muted-foreground mt-1">
Build a Docker image tailored to your workflow.
</p>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle>Environment Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="env-name">Environment name</Label>
<Input id="env-name" value={name} onChange={(event) => setName(event.target.value)} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Base image</Label>
<Select value={baseImage} onValueChange={(value) => setBaseImage(value as BaseDocker)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(BASE_IMAGE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="image-tag">Image tag</Label>
<Input id="image-tag" value={imageTag} onChange={(event) => setImageTag(event.target.value)} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconCube className="h-4 w-4" />
Image Build
</CardTitle>
<CardDescription>Select agents to install in the image.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:grid-cols-3">
{AGENT_OPTIONS.map((agent) => (
<label
key={agent.id}
className="flex items-start gap-2 rounded-md border border-border/70 p-2 text-sm"
>
<input
type="checkbox"
className="mt-0.5"
checked={installAgents.includes(agent.id)}
onChange={() => handleToggleAgent(agent.id)}
/>
<span>
<span className="font-medium block">{agent.label}</span>
<span className="text-xs text-muted-foreground">{agent.description}</span>
</span>
</label>
))}
</div>
<p className="text-xs text-muted-foreground">
Agent folders will be mounted as volumes at runtime.
</p>
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-xs text-muted-foreground">
Repositories are mounted into the container at runtime.
</p>
<Button type="button" onClick={handleBuildImage}>
Build image
</Button>
</div>
{showDockerfileEditor && (
<div className="space-y-2">
<Label htmlFor="dockerfile">Dockerfile</Label>
<Textarea
id="dockerfile"
value={dockerfile}
onChange={(event) => setDockerfile(event.target.value)}
rows={12}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Edit the Dockerfile before building locally.
</p>
</div>
)}
</CardContent>
</Card>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => router.push('/settings/environments')}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating ? 'Creating...' : 'Create Environment'}
</Button>
</div>
</div>
);
}
+47 -175
View File
@@ -1,201 +1,73 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { IconPlus } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Textarea } from '@kandev/ui/textarea';
import { Card, CardContent } from '@kandev/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Separator } from '@kandev/ui/separator';
import { EnvironmentCard } from '@/components/settings/environment-card';
import { KeyValueInput } from '@/components/settings/key-value-input';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Environment, EnvironmentType, BaseDocker, KeyValue, AgentType } from '@/lib/settings/types';
import { generateUUID } from '@/lib/utils';
import { listEnvironmentsAction } from '@/app/actions/environments';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Environment } from '@/lib/types/http';
export default function EnvironmentsSettingsPage() {
const [environments, setEnvironments] = useState<Environment[]>(SETTINGS_DATA.environments);
const [isAdding, setIsAdding] = useState(false);
const [newEnv, setNewEnv] = useState<{
name: string;
type: EnvironmentType;
baseDocker: BaseDocker;
envVariables: KeyValue[];
secrets: KeyValue[];
setupScript: string;
installedAgents: AgentType[];
}>({
name: '',
type: 'local-docker',
baseDocker: 'universal',
envVariables: [],
secrets: [],
setupScript: '',
installedAgents: [],
});
const [environments, setEnvironments] = useState<Environment[]>([]);
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const environment: Environment = {
id: generateUUID(),
...newEnv,
};
setEnvironments([...environments, environment]);
setIsAdding(false);
setNewEnv({
name: '',
type: 'local-docker',
baseDocker: 'universal',
envVariables: [],
secrets: [],
setupScript: '',
installedAgents: [],
});
};
useEffect(() => {
const client = getWebSocketClient();
const fallback = () =>
SETTINGS_DATA.environments.map((env) => ({
id: env.id,
name: env.name,
kind: env.kind,
worktree_root: env.worktreeRoot,
image_tag: env.imageTag,
dockerfile: env.dockerfile,
build_config: env.buildConfig
? {
base_image: env.buildConfig.baseImage,
install_agents: env.buildConfig.installAgents.join(','),
}
: undefined,
created_at: '',
updated_at: '',
}));
if (client) {
client
.request<{ environments: Environment[] }>('environment.list', {})
.then((resp) => setEnvironments(resp.environments))
.catch(() => setEnvironments(fallback()));
return;
}
listEnvironmentsAction()
.then((resp) => setEnvironments(resp.environments))
.catch(() => setEnvironments(fallback()));
}, []);
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold">Environments</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure execution environments for your agents
Configure runtime environments for agent sessions.
</p>
</div>
<Button size="sm" onClick={() => setIsAdding(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Environment
<Button asChild size="sm">
<Link href="/settings/environment/new">
<IconPlus className="h-4 w-4 mr-2" />
Create Custom Environment
</Link>
</Button>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-3">
{isAdding && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAdd} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="env-name">Environment Name</Label>
<Input
id="env-name"
value={newEnv.name}
onChange={(e) => setNewEnv({ ...newEnv, name: e.target.value })}
placeholder="Development"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="env-type">Environment Type</Label>
<Select
value={newEnv.type}
onValueChange={(value) =>
setNewEnv({ ...newEnv, type: value as EnvironmentType })
}
>
<SelectTrigger id="env-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local-docker">Local Docker</SelectItem>
<SelectItem value="remote-docker">Remote Docker</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="base-docker">Base Docker Image</Label>
<Select
value={newEnv.baseDocker}
onValueChange={(value) =>
setNewEnv({ ...newEnv, baseDocker: value as BaseDocker })
}
>
<SelectTrigger id="base-docker">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="universal">Universal</SelectItem>
<SelectItem value="golang">Golang</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Environment Variables</Label>
<KeyValueInput
items={newEnv.envVariables}
onChange={(items) => setNewEnv({ ...newEnv, envVariables: items })}
keyPlaceholder="Variable name"
valuePlaceholder="Value"
addButtonLabel="Add Variable"
/>
</div>
<div className="space-y-2">
<Label>Secrets</Label>
<KeyValueInput
items={newEnv.secrets}
onChange={(items) => setNewEnv({ ...newEnv, secrets: items })}
keyPlaceholder="Secret name"
valuePlaceholder="Secret value"
addButtonLabel="Add Secret"
masked
/>
</div>
<div className="space-y-2">
<Label htmlFor="setup-script">Setup Script</Label>
<Textarea
id="setup-script"
value={newEnv.setupScript}
onChange={(e) =>
setNewEnv({ ...newEnv, setupScript: e.target.value })
}
placeholder="#!/bin/bash&#10;apt-get update&#10;apt-get install -y git"
rows={5}
className="font-mono text-sm"
/>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => setIsAdding(false)}
>
Cancel
</Button>
<Button type="submit">Add Environment</Button>
</div>
</form>
</CardContent>
</Card>
)}
{environments.map((env) => (
<EnvironmentCard
key={env.id}
environment={env}
/>
))}
{environments.length === 0 && !isAdding && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No environments configured. Add your first environment to get started.
</p>
</CardContent>
</Card>
)}
</div>
<div className="grid gap-3">
{environments.map((env) => (
<EnvironmentCard key={env.id} environment={env} />
))}
</div>
</div>
);
@@ -0,0 +1,282 @@
'use client';
import { use, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { IconCpu, IconServer, 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 { Separator } from '@kandev/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kandev/ui/dialog';
import { getExecutorAction, updateExecutorAction, deleteExecutorAction } from '@/app/actions/executors';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Executor } from '@/lib/types/http';
export default function ExecutorEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [executor, setExecutor] = useState<Executor | null>(null);
const [savedExecutor, setSavedExecutor] = useState<Executor | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
getExecutorAction(id)
.then((data) => {
setExecutor(data);
setSavedExecutor(data);
})
.catch(() => setExecutor(null))
.finally(() => setIsLoading(false));
}, [id]);
const isSystem = executor?.is_system ?? false;
const isLocalDocker = executor?.type === 'local_docker';
const ExecutorIcon = useMemo(() => {
return executor?.type === 'local_pc' ? IconCpu : IconServer;
}, [executor?.type]);
const isDirty = useMemo(() => {
if (!executor || !savedExecutor) return false;
return (
executor.name !== savedExecutor.name ||
executor.type !== savedExecutor.type ||
executor.status !== savedExecutor.status ||
JSON.stringify(executor.config ?? {}) !== JSON.stringify(savedExecutor.config ?? {})
);
}, [executor, savedExecutor]);
if (isLoading) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Loading executor...</p>
</CardContent>
</Card>
</div>
);
}
if (!executor) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Executor not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/executors')}>
Go to Executors
</Button>
</CardContent>
</Card>
</div>
);
}
const handleSaveExecutor = async () => {
if (!executor) return;
setIsSaving(true);
try {
const payload = {
name: executor.name,
type: executor.type,
status: executor.status,
config: executor.config ?? {},
};
const client = getWebSocketClient();
const updated = client
? await client.request<Executor>('executor.update', { id: executor.id, ...payload })
: await updateExecutorAction(executor.id, payload);
setExecutor(updated);
setSavedExecutor(updated);
router.push('/settings/executors');
} finally {
setIsSaving(false);
}
};
const handleDeleteExecutor = async () => {
if (deleteConfirmText !== 'delete') return;
setIsDeleting(true);
try {
const client = getWebSocketClient();
if (client) {
await client.request('executor.delete', { id: executor.id });
} else {
await deleteExecutorAction(executor.id);
}
router.push('/settings/executors');
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
}
};
return (
<div className="space-y-8">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h2 className="text-2xl font-bold">{executor.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
{executor.type === 'local_pc'
? 'Uses locally installed agents on this machine.'
: executor.type === 'local_docker'
? 'Runs Docker containers on this machine.'
: 'Remote Docker support is coming soon.'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => router.push('/settings/executors')}>
Back to Executors
</Button>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle>Executor Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ExecutorIcon className="h-4 w-4" />
<span>{executor.type}</span>
</div>
<div className="space-y-2">
<Label htmlFor="executor-name">Executor name</Label>
<Input
id="executor-name"
value={executor.name}
onChange={(event) => setExecutor({ ...executor, name: event.target.value })}
disabled={isSystem}
/>
{isSystem && (
<p className="text-xs text-muted-foreground">System executor names cannot be edited.</p>
)}
</div>
</CardContent>
</Card>
{isLocalDocker && (
<Card>
<CardHeader>
<CardTitle>Docker Configuration</CardTitle>
<CardDescription>Local Docker host settings.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Label htmlFor="docker-host">Docker host env value</Label>
<Input
id="docker-host"
value={executor.config?.docker_host ?? ''}
onChange={(event) =>
setExecutor({
...executor,
config: { ...(executor.config ?? {}), docker_host: event.target.value },
})
}
placeholder="unix:///var/run/docker.sock"
/>
<p className="text-xs text-muted-foreground">
The repository will be mounted as a volume during runtime.
</p>
</CardContent>
</Card>
)}
{executor.type === 'remote_docker' && (
<Card>
<CardHeader>
<CardTitle>Remote Docker</CardTitle>
<CardDescription>Coming soon.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Remote Docker executors will support SSH and cloud runtimes in a future update.
</p>
</CardContent>
</Card>
)}
<Separator />
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => router.push('/settings/executors')}>
Cancel
</Button>
<Button onClick={handleSaveExecutor} disabled={!isDirty || isSystem || isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
{!isSystem && (
<>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>Irreversible actions that permanently delete this executor.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete this executor</p>
<p className="text-sm text-muted-foreground">
Once deleted, environments using this executor will need to be updated.
</p>
</div>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Executor
</Button>
</div>
</CardContent>
</Card>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Executor</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the executor &quot;
{executor.name}&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
Please type <span className="font-mono font-bold">delete</span> to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteExecutor}
disabled={deleteConfirmText !== 'delete'}
>
{isDeleting ? 'Deleting...' : 'Delete Executor'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { IconCloud, IconServer } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kandev/ui/card';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Separator } from '@kandev/ui/separator';
import { createExecutorAction } from '@/app/actions/executors';
import { getWebSocketClient } from '@/lib/ws/connection';
const EXECUTOR_TYPES = ['local_docker', 'remote_docker'] as const;
type ExecutorType = (typeof EXECUTOR_TYPES)[number];
export default function ExecutorCreatePage() {
const router = useRouter();
const searchParams = useSearchParams();
const initialType = searchParams.get('type');
const [type, setType] = useState<ExecutorType>(() => {
if (EXECUTOR_TYPES.includes(initialType as ExecutorType)) {
return initialType as ExecutorType;
}
return 'local_docker';
});
const [name, setName] = useState('Local Docker');
const [dockerHost, setDockerHost] = useState('unix:///var/run/docker.sock');
const [isCreating, setIsCreating] = useState(false);
const handleCreate = async () => {
if (type !== 'local_docker') {
return;
}
setIsCreating(true);
try {
const payload = {
name,
type,
status: 'active',
config: { docker_host: dockerHost },
};
const client = getWebSocketClient();
if (client) {
await client.request('executor.create', payload);
} else {
await createExecutorAction(payload);
}
router.push('/settings/executors');
} finally {
setIsCreating(false);
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">Create Executor</h2>
<p className="text-sm text-muted-foreground mt-1">
Choose an executor type to run environments on your infrastructure.
</p>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{type === 'local_docker' ? <IconServer className="h-4 w-4" /> : <IconCloud className="h-4 w-4" />}
{type === 'local_docker' ? 'Local Docker Executor' : 'Remote Docker Executor'}
</CardTitle>
<CardDescription>
{type === 'local_docker'
? 'Uses the local Docker daemon on this machine.'
: 'Connects to a remote Docker host (coming soon).'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="executor-type">Executor type</Label>
<Select value={type} onValueChange={(value) => setType(value as ExecutorType)}>
<SelectTrigger id="executor-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local_docker">Local Docker</SelectItem>
<SelectItem value="remote_docker" disabled>
Remote Docker (coming soon)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="executor-name">Executor name</Label>
<Input
id="executor-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="docker-host">Docker host env value</Label>
<Input
id="docker-host"
value={dockerHost}
onChange={(event) => setDockerHost(event.target.value)}
placeholder="unix:///var/run/docker.sock"
disabled={type !== 'local_docker'}
/>
<p className="text-xs text-muted-foreground">
Repositories will be mounted as volumes at runtime.
</p>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => router.push('/settings/executors')}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isCreating || type !== 'local_docker'}>
{isCreating ? 'Creating...' : 'Create Executor'}
</Button>
</div>
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { IconChevronRight, IconCloud, IconCpu, IconServer } from '@tabler/icons-react';
import { Badge } from '@kandev/ui/badge';
import { Card, CardContent } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import { listExecutorsAction } from '@/app/actions/executors';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Executor } from '@/lib/types/http';
import { getWebSocketClient } from '@/lib/ws/connection';
export default function ExecutorsSettingsPage() {
const [executors, setExecutors] = useState<Executor[]>([]);
const creationOptions = [
{
id: 'local_docker',
label: 'Local Docker',
description: 'Run on the local Docker daemon.',
href: '/settings/executor/new?type=local_docker',
icon: IconServer,
enabled: true,
},
{
id: 'remote_docker',
label: 'Remote Docker',
description: 'Connect to a remote Docker host.',
href: '/settings/executor/new?type=remote_docker',
icon: IconCloud,
enabled: false,
},
{
id: 'remote_server',
label: 'Remote Server',
description: 'SSH into a dedicated host.',
href: '/settings/executor/new?type=remote_server',
icon: IconCloud,
enabled: false,
},
{
id: 'k8s',
label: 'K8s',
description: 'Run workloads in a Kubernetes cluster.',
href: '/settings/executor/new?type=k8s',
icon: IconCloud,
enabled: false,
},
];
useEffect(() => {
const client = getWebSocketClient();
const fallback = () =>
SETTINGS_DATA.executors.map((executor) => ({
id: executor.id,
name: executor.name,
type: executor.type,
status: executor.status,
is_system: executor.isSystem,
config: executor.config,
created_at: '',
updated_at: '',
}));
if (client) {
client
.request<{ executors: Executor[] }>('executor.list', {})
.then((resp) => setExecutors(resp.executors))
.catch(() => setExecutors(fallback()));
return;
}
listExecutorsAction()
.then((resp) => setExecutors(resp.executors))
.catch(() => setExecutors(fallback()));
}, []);
return (
<div className="space-y-8">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold">Executors</h2>
<p className="text-sm text-muted-foreground mt-1">
Choose where environments run today and prepare for remote targets.
</p>
</div>
</div>
<Separator />
<div>
<div className="text-sm font-medium mb-3">Create an executor</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{creationOptions.map((option) => {
const Icon = option.icon;
const cardBody = (
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-md">
<Icon className="h-4 w-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{option.label}</span>
{!option.enabled && (
<Badge variant="outline" className="text-xs">
Coming soon
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-1">{option.description}</p>
</div>
</div>
</CardContent>
);
if (!option.enabled) {
return (
<div key={option.id} className="cursor-not-allowed opacity-60">
<Card className="h-full">{cardBody}</Card>
</div>
);
}
return (
<Link key={option.id} href={option.href} className="block">
<Card className="h-full hover:bg-accent transition-colors">
{cardBody}
</Card>
</Link>
);
})}
</div>
</div>
<div className="grid gap-3">
{executors.map((executor) => {
const Icon = executor.type === 'local_pc' ? IconCpu : IconServer;
const typeLabel =
executor.type === 'local_pc'
? 'Local PC'
: executor.type === 'local_docker'
? 'Local Docker'
: 'Remote Docker';
return (
<Link key={executor.id} href={`/settings/executor/${executor.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium">{executor.name}</h4>
<Badge variant="secondary" className="text-xs">
{typeLabel}
</Badge>
{executor.status === 'disabled' && (
<Badge variant="outline" className="text-xs">
Disabled
</Badge>
)}
</div>
{executor.type === 'local_docker' && executor.config?.docker_host && (
<div className="text-xs text-muted-foreground mt-1">
{executor.config.docker_host}
</div>
)}
</div>
</div>
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
);
}
+30 -1
View File
@@ -2,7 +2,7 @@
import { useState, useSyncExternalStore } from 'react';
import { useTheme } from 'next-themes';
import { IconPalette, IconCode, IconBell, IconServer } from '@tabler/icons-react';
import { IconPalette, IconCode, IconBell, IconServer, IconTerminal2 } from '@tabler/icons-react';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Label } from '@kandev/ui/label';
import { Input } from '@kandev/ui/input';
@@ -252,6 +252,35 @@ export default function GeneralSettingsPage() {
</CardContent>
</Card>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconTerminal2 className="h-5 w-5" />}
title="Worktree Root"
description="Configure where local worktrees are created"
>
<Card>
<CardHeader>
<CardTitle className="text-base">Local Worktree Path</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="worktree-root">Worktree Root</Label>
<Input
id="worktree-root"
value="~/kandev"
readOnly
disabled
className="cursor-default"
/>
<p className="text-xs text-muted-foreground">
Custom paths will be available in a future update.
</p>
</div>
</CardContent>
</Card>
</SettingsSection>
</div>
);
}
@@ -1,13 +1,15 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { IconGitBranch, IconLayoutColumns, IconTrash } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import {
Dialog,
DialogContent,
@@ -17,7 +19,9 @@ import {
DialogTitle,
} from '@kandev/ui/dialog';
import { updateWorkspaceAction, deleteWorkspaceAction } from '@/app/actions/workspaces';
import type { Workspace } from '@/lib/types/http';
import { listExecutorsAction } from '@/app/actions/executors';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Executor, Workspace } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
import { useAppStore } from '@/components/state-provider';
@@ -33,12 +37,35 @@ export function WorkspaceEditClient({ workspace }: WorkspaceEditClientProps) {
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(workspace);
const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace?.name ?? '');
const [savedWorkspaceName, setSavedWorkspaceName] = useState(workspace?.name ?? '');
const [savedDefaultExecutorId, setSavedDefaultExecutorId] = useState(
workspace?.default_executor_id ?? ''
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [defaultExecutorId, setDefaultExecutorId] = useState(
workspace?.default_executor_id ?? ''
);
const [executors, setExecutors] = useState<Executor[]>([]);
const saveWorkspaceRequest = useRequest(updateWorkspaceAction);
const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction);
const workspaces = useAppStore((state) => state.workspaces.items);
const setWorkspaces = useAppStore((state) => state.setWorkspaces);
const activeExecutors = executors.filter((executor) => executor.status === 'active');
useEffect(() => {
const client = getWebSocketClient();
if (client) {
client
.request<{ executors: Executor[] }>('executor.list', {})
.then((resp) => setExecutors(resp.executors))
.catch(() => setExecutors([]));
return;
}
listExecutorsAction()
.then((resp) => setExecutors(resp.executors))
.catch(() => setExecutors([]));
}, []);
const handleSaveWorkspaceName = async () => {
if (!currentWorkspace) return;
@@ -77,6 +104,24 @@ export function WorkspaceEditClient({ workspace }: WorkspaceEditClientProps) {
}
};
const handleSaveDefaultExecutor = async () => {
if (!currentWorkspace) return;
if (!defaultExecutorId || defaultExecutorId === savedDefaultExecutorId) return;
try {
const updated = await saveWorkspaceRequest.run(currentWorkspace.id, {
default_executor_id: defaultExecutorId,
});
setCurrentWorkspace((prev) => (prev ? { ...prev, ...updated } : prev));
setSavedDefaultExecutorId(updated.default_executor_id ?? '');
} catch (error) {
toast({
title: 'Failed to save executor',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
if (!currentWorkspace) {
return (
<div>
@@ -130,6 +175,40 @@ export function WorkspaceEditClient({ workspace }: WorkspaceEditClientProps) {
<Separator />
<Card>
<CardHeader>
<CardTitle>Default Executor</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Label htmlFor="default-executor">Executor</Label>
<Select value={defaultExecutorId} onValueChange={setDefaultExecutorId}>
<SelectTrigger id="default-executor">
<SelectValue placeholder="Select executor" />
</SelectTrigger>
<SelectContent>
{activeExecutors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<UnsavedSaveButton
isDirty={defaultExecutorId !== savedDefaultExecutorId}
isLoading={saveWorkspaceRequest.isLoading}
status={saveWorkspaceRequest.status}
onClick={handleSaveDefaultExecutor}
/>
<p className="text-xs text-muted-foreground">
Select which executor new sessions should default to.
</p>
</div>
</CardContent>
</Card>
<Separator />
<Card>
<CardHeader>
<CardTitle>Workspace Links</CardTitle>
@@ -4,15 +4,15 @@ import Link from 'next/link';
import { IconServer, IconChevronRight } from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Badge } from '@kandev/ui/badge';
import type { Environment } from '@/lib/settings/types';
import type { Environment } from '@/lib/types/http';
type EnvironmentCardProps = {
environment: Environment;
};
export function EnvironmentCard({ environment }: EnvironmentCardProps) {
const typeLabel = environment.type === 'local-docker' ? 'Local Docker' : 'Remote Docker';
const baseDockerLabel = environment.baseDocker === 'universal' ? 'Universal' : 'Golang';
const typeLabel = environment.kind === 'local_pc' ? 'Local' : 'Custom Image';
const imageLabel = environment.image_tag || 'Unbuilt';
return (
<Link href={`/settings/environment/${environment.id}`}>
@@ -29,15 +29,17 @@ export function EnvironmentCard({ environment }: EnvironmentCardProps) {
<Badge variant="secondary" className="text-xs">
{typeLabel}
</Badge>
<Badge variant="outline" className="text-xs">
{baseDockerLabel}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{environment.envVariables.length} variables</span>
<span>{environment.secrets.length} secrets</span>
<span>{(environment.installedAgents || []).length} agents</span>
{environment.kind === 'docker_image' && (
<Badge variant="outline" className="text-xs">
{imageLabel}
</Badge>
)}
</div>
{environment.kind === 'local_pc' && environment.worktree_root && (
<div className="text-xs text-muted-foreground mt-1">
Worktree: {environment.worktree_root}
</div>
)}
</div>
</div>
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
@@ -9,6 +9,7 @@ import {
IconServer,
IconRobot,
IconChevronRight,
IconCpu,
IconPlug,
IconPuzzle,
} from '@tabler/icons-react';
@@ -29,13 +30,170 @@ import {
} from '@kandev/ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@kandev/ui/collapsible';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import { listEnvironmentsAction } from '@/app/actions/environments';
import { listExecutorsAction } from '@/app/actions/executors';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Environment, Executor } from '@/lib/types/http';
import { useAppStore } from '@/components/state-provider';
export function SettingsAppSidebar() {
const pathname = usePathname();
const workspaces = useAppStore((state) => state.workspaces.items);
const [environments] = React.useState(SETTINGS_DATA.environments);
const fallbackEnvironments = React.useMemo<Environment[]>(
() =>
SETTINGS_DATA.environments.map((env) => ({
id: env.id,
name: env.name,
kind: env.kind,
worktree_root: env.worktreeRoot,
image_tag: env.imageTag,
dockerfile: env.dockerfile,
build_config: env.buildConfig
? {
base_image: env.buildConfig.baseImage,
install_agents: env.buildConfig.installAgents.join(','),
}
: undefined,
created_at: '',
updated_at: '',
})),
[]
);
const fallbackExecutors = React.useMemo<Executor[]>(
() =>
SETTINGS_DATA.executors.map((executor) => ({
id: executor.id,
name: executor.name,
type: executor.type,
status: executor.status,
is_system: executor.isSystem,
config: executor.config,
created_at: '',
updated_at: '',
})),
[]
);
const [environments, setEnvironments] = React.useState<Environment[]>(fallbackEnvironments);
const [agents] = React.useState(SETTINGS_DATA.agents);
const [executors, setExecutors] = React.useState<Executor[]>(fallbackExecutors);
React.useEffect(() => {
const client = getWebSocketClient();
const envFallback = () => fallbackEnvironments;
const execFallback = () => fallbackExecutors;
if (client) {
client
.request<{ environments: Environment[] }>('environment.list', {})
.then((resp) => setEnvironments(resp.environments))
.catch(() => setEnvironments(envFallback()));
client
.request<{ executors: Executor[] }>('executor.list', {})
.then((resp) => setExecutors(resp.executors))
.catch(() => setExecutors(execFallback()));
return;
}
listEnvironmentsAction()
.then((resp) => setEnvironments(resp.environments))
.catch(() => setEnvironments(envFallback()));
listExecutorsAction()
.then((resp) => setExecutors(resp.executors))
.catch(() => setExecutors(execFallback()));
}, [fallbackEnvironments, fallbackExecutors]);
React.useEffect(() => {
const client = getWebSocketClient();
if (!client) {
return;
}
const unsubscribeEnvironmentCreated = client.on('environment.created', (message) => {
setEnvironments((prev) => {
const exists = prev.some((env) => env.id === message.payload.id);
const next = {
id: message.payload.id,
name: message.payload.name,
kind: message.payload.kind,
worktree_root: message.payload.worktree_root,
image_tag: message.payload.image_tag,
dockerfile: message.payload.dockerfile,
build_config: message.payload.build_config,
created_at: message.payload.created_at ?? '',
updated_at: message.payload.updated_at ?? '',
};
return exists ? prev.map((env) => (env.id === next.id ? next : env)) : [next, ...prev];
});
});
const unsubscribeEnvironmentUpdated = client.on('environment.updated', (message) => {
setEnvironments((prev) =>
prev.map((env) =>
env.id === message.payload.id
? {
...env,
name: message.payload.name,
kind: message.payload.kind,
worktree_root: message.payload.worktree_root,
image_tag: message.payload.image_tag,
dockerfile: message.payload.dockerfile,
build_config: message.payload.build_config,
updated_at: message.payload.updated_at ?? env.updated_at,
}
: env
)
);
});
const unsubscribeEnvironmentDeleted = client.on('environment.deleted', (message) => {
setEnvironments((prev) => prev.filter((env) => env.id !== message.payload.id));
});
const unsubscribeExecutorCreated = client.on('executor.created', (message) => {
setExecutors((prev) => {
const exists = prev.some((executor) => executor.id === message.payload.id);
const next = {
id: message.payload.id,
name: message.payload.name,
type: message.payload.type,
status: message.payload.status,
is_system: message.payload.is_system,
config: message.payload.config,
created_at: message.payload.created_at ?? '',
updated_at: message.payload.updated_at ?? '',
};
return exists
? prev.map((executor) => (executor.id === next.id ? next : executor))
: [next, ...prev];
});
});
const unsubscribeExecutorUpdated = client.on('executor.updated', (message) => {
setExecutors((prev) =>
prev.map((executor) =>
executor.id === message.payload.id
? {
...executor,
name: message.payload.name,
type: message.payload.type,
status: message.payload.status,
config: message.payload.config,
updated_at: message.payload.updated_at ?? executor.updated_at,
}
: executor
)
);
});
const unsubscribeExecutorDeleted = client.on('executor.deleted', (message) => {
setExecutors((prev) => prev.filter((executor) => executor.id !== message.payload.id));
});
return () => {
unsubscribeEnvironmentCreated();
unsubscribeEnvironmentUpdated();
unsubscribeEnvironmentDeleted();
unsubscribeExecutorCreated();
unsubscribeExecutorUpdated();
unsubscribeExecutorDeleted();
};
}, []);
return (
<Sidebar variant="inset">
@@ -177,6 +335,44 @@ export function SettingsAppSidebar() {
</SidebarMenuItem>
</Collapsible>
{/* Executors */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Executors">
<Link href="/settings/executors">
<IconCpu className="h-4 w-4" />
<span>Executors</span>
</Link>
</SidebarMenuButton>
{executors.length > 0 && (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{executors.map((executor) => (
<SidebarMenuSubItem key={executor.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/executor/${executor.id}`}
>
<Link href={`/settings/executor/${executor.id}`}>
<span>{executor.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
{/* Agents */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
+42 -25
View File
@@ -75,34 +75,51 @@ export const SETTINGS_DATA: SettingsData = {
],
},
],
environments: [
executors: [
{
id: '1',
name: 'Development',
type: 'local-docker',
baseDocker: 'universal',
envVariables: [
{ id: 'e1', key: 'NODE_ENV', value: 'development' },
{ id: 'e2', key: 'PORT', value: '3000' },
],
secrets: [
{ id: 'se1', key: 'DATABASE_PASSWORD', value: '••••••••' },
{ id: 'se2', key: 'API_SECRET', value: '••••••••' },
],
setupScript: '#!/bin/bash\napt-get update\napt-get install -y git curl',
installedAgents: ['claude-code', 'codex'],
id: 'exec-local-pc',
name: 'Local PC',
type: 'local_pc',
status: 'active',
isSystem: true,
config: {},
},
{
id: '2',
name: 'Production',
type: 'remote-docker',
baseDocker: 'golang',
envVariables: [
{ id: 'e3', key: 'NODE_ENV', value: 'production' },
],
secrets: [],
setupScript: '',
installedAgents: ['auggie'],
id: 'exec-local-docker',
name: 'Local Docker',
type: 'local_docker',
status: 'active',
isSystem: false,
config: {
docker_host: 'unix:///var/run/docker.sock',
},
},
{
id: 'exec-remote-docker',
name: 'Remote Docker',
type: 'remote_docker',
status: 'disabled',
isSystem: false,
config: {},
},
],
environments: [
{
id: 'env-local',
name: 'Local',
kind: 'local_pc',
worktreeRoot: '~/kandev',
},
{
id: 'env-custom',
name: 'Custom Image',
kind: 'docker_image',
imageTag: 'kandev/custom:dev',
dockerfile: '',
buildConfig: {
baseImage: 'universal',
installAgents: ['claude-code', 'codex'],
},
},
],
agents: [
+25 -8
View File
@@ -55,18 +55,34 @@ export type KeyValue = {
value: string;
};
export type EnvironmentType = 'local-docker' | 'remote-docker';
export type BaseDocker = 'universal' | 'golang';
export type ExecutorType = 'local_pc' | 'local_docker' | 'remote_docker' | 'remote_vps' | 'k8s';
export type ExecutorStatus = 'active' | 'disabled';
export type Executor = {
id: string;
name: string;
type: ExecutorType;
status: ExecutorStatus;
isSystem: boolean;
config: Record<string, string>;
};
export type EnvironmentKind = 'local_pc' | 'docker_image';
export type BaseDocker = 'universal' | 'golang' | 'node' | 'python';
export type EnvironmentBuildConfig = {
baseImage: BaseDocker;
installAgents: AgentType[];
};
export type Environment = {
id: string;
name: string;
type: EnvironmentType;
baseDocker: BaseDocker;
envVariables: KeyValue[];
secrets: KeyValue[];
setupScript: string;
installedAgents: AgentType[];
kind: EnvironmentKind;
worktreeRoot?: string;
imageTag?: string;
dockerfile?: string;
buildConfig?: EnvironmentBuildConfig;
};
export type AgentType = 'claude-code' | 'codex' | 'auggie';
@@ -84,5 +100,6 @@ export type SettingsData = {
general: GeneralSettings;
workspaces: Workspace[];
environments: Environment[];
executors: Executor[];
agents: AgentProfile[];
};
+36 -1
View File
@@ -18,7 +18,13 @@ export type BackendMessageType =
| 'column.created'
| 'column.updated'
| 'column.deleted'
| 'comment.added';
| 'comment.added'
| 'executor.created'
| 'executor.updated'
| 'executor.deleted'
| 'environment.created'
| 'environment.updated'
| 'environment.deleted';
export type BackendMessage<T extends BackendMessageType, P> = {
id?: string;
@@ -146,6 +152,29 @@ export type GitStatusPayload = {
timestamp: string;
};
export type ExecutorPayload = {
id: string;
name: string;
type: string;
status: string;
is_system: boolean;
config?: Record<string, string>;
created_at?: string;
updated_at?: string;
};
export type EnvironmentPayload = {
id: string;
name: string;
kind: string;
worktree_root?: string;
image_tag?: string;
dockerfile?: string;
build_config?: Record<string, string>;
created_at?: string;
updated_at?: string;
};
export type BackendMessageMap = {
'kanban.update': BackendMessage<'kanban.update', KanbanUpdatePayload>;
'task.created': BackendMessage<'task.created', TaskEventPayload>;
@@ -167,4 +196,10 @@ export type BackendMessageMap = {
'column.updated': BackendMessage<'column.updated', ColumnPayload>;
'column.deleted': BackendMessage<'column.deleted', ColumnPayload>;
'comment.added': BackendMessage<'comment.added', CommentAddedPayload>;
'executor.created': BackendMessage<'executor.created', ExecutorPayload>;
'executor.updated': BackendMessage<'executor.updated', ExecutorPayload>;
'executor.deleted': BackendMessage<'executor.deleted', ExecutorPayload>;
'environment.created': BackendMessage<'environment.created', EnvironmentPayload>;
'environment.updated': BackendMessage<'environment.updated', EnvironmentPayload>;
'environment.deleted': BackendMessage<'environment.deleted', EnvironmentPayload>;
};
+34
View File
@@ -24,6 +24,7 @@ export type Workspace = {
name: string;
description?: string | null;
owner_id: string;
default_executor_id?: string | null;
created_at: string;
updated_at: string;
};
@@ -145,6 +146,39 @@ export type ListWorkspacesResponse = {
total: number;
};
export type Executor = {
id: string;
name: string;
type: string;
status: string;
is_system: boolean;
config?: Record<string, string>;
created_at: string;
updated_at: string;
};
export type Environment = {
id: string;
name: string;
kind: string;
worktree_root?: string | null;
image_tag?: string | null;
dockerfile?: string | null;
build_config?: Record<string, string> | null;
created_at: string;
updated_at: string;
};
export type ListExecutorsResponse = {
executors: Executor[];
total: number;
};
export type ListEnvironmentsResponse = {
environments: Environment[];
total: number;
};
export type CommentAuthorType = 'user' | 'agent';
export type CommentType = 'message' | 'content' | 'tool_call' | 'progress' | 'error' | 'status';