feat: environments and executors
This commit is contained in:
@@ -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)")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 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 "
|
||||
{environment.name}" 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 "
|
||||
{environment.name}" 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>
|
||||
);
|
||||
}
|
||||
@@ -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 apt-get update 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 "
|
||||
{executor.name}".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user