From 3fe1418d5e4995be72b4208a419967167f512222 Mon Sep 17 00:00:00 2001 From: litiantian03 Date: Mon, 18 May 2026 16:29:37 +0800 Subject: [PATCH 1/2] feat(team): Multi-agent Team control plane across runtimes (Leader-mediated collaboration) - Add migrations plus Team/Member/Task/Event APIs; Redis Streams consumer projects inbox/events into DB as source of truth - Inject Team Secret (Redis URL, team token) via envFrom; shared RWX PVC at /team; sync ConfigMap roster to /team/team.json - Create member Pods through InstanceService; extend K8s (PVC/Secret/Pod/ConfigMap); stale-task sweep and Team/member deletion with cleanup - Add /teams, /teams/new, /teams/:id: creation wizard (roster, presets, shared env/OpenClaw plan), per-member desktops, collaboration timeline, debug dispatch (defaults to Leader when target omitted) --- backend/cmd/server/main.go | 17 + .../db/migrations/014_add_team_mvp.sql | 90 + .../015_add_team_member_description.sql | 15 + .../016_release_deleted_team_names.sql | 8 + .../017_add_team_member_runtime_state.sql | 95 + .../018_add_team_member_runtime_type.sql | 15 + backend/internal/handlers/team_handler.go | 128 ++ backend/internal/models/team.go | 124 ++ .../repository/instance_repository.go | 2 +- .../internal/repository/team_repository.go | 290 +++ backend/internal/services/instance_service.go | 165 +- backend/internal/services/k8s/client.go | 15 + .../services/k8s/configmap_service.go | 83 + backend/internal/services/k8s/pod_service.go | 95 +- .../internal/services/k8s/pod_service_test.go | 61 + backend/internal/services/k8s/pvc_service.go | 208 ++ .../internal/services/k8s/secret_service.go | 15 + backend/internal/services/team_redis.go | 303 +++ backend/internal/services/team_service.go | 1798 ++++++++++++++++ .../internal/services/team_service_test.go | 288 +++ backend/internal/utils/response.go | 10 +- deployments/k8s/clawmanager.yaml | 55 + frontend/src/components/InstanceAccess.tsx | 11 +- frontend/src/components/UserLayout.tsx | 1 + .../src/hooks/useInstanceDesktopAccess.ts | 26 + .../src/pages/instances/InstanceListPage.tsx | 30 +- frontend/src/pages/teams/CreateTeamPage.tsx | 1670 +++++++++++++++ frontend/src/pages/teams/TeamDetailPage.tsx | 1855 +++++++++++++++++ frontend/src/pages/teams/TeamListPage.tsx | 202 ++ frontend/src/router/index.tsx | 27 + frontend/src/services/teamService.ts | 46 + frontend/src/types/team.ts | 134 ++ 32 files changed, 7841 insertions(+), 41 deletions(-) create mode 100644 backend/internal/db/migrations/014_add_team_mvp.sql create mode 100644 backend/internal/db/migrations/015_add_team_member_description.sql create mode 100644 backend/internal/db/migrations/016_release_deleted_team_names.sql create mode 100644 backend/internal/db/migrations/017_add_team_member_runtime_state.sql create mode 100644 backend/internal/db/migrations/018_add_team_member_runtime_type.sql create mode 100644 backend/internal/handlers/team_handler.go create mode 100644 backend/internal/models/team.go create mode 100644 backend/internal/repository/team_repository.go create mode 100644 backend/internal/services/k8s/configmap_service.go create mode 100644 backend/internal/services/team_redis.go create mode 100644 backend/internal/services/team_service.go create mode 100644 backend/internal/services/team_service_test.go create mode 100644 frontend/src/pages/teams/CreateTeamPage.tsx create mode 100644 frontend/src/pages/teams/TeamDetailPage.tsx create mode 100644 frontend/src/pages/teams/TeamListPage.tsx create mode 100644 frontend/src/services/teamService.ts create mode 100644 frontend/src/types/team.ts diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c8d950c..6198a54 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -65,6 +65,7 @@ func main() { instanceDesiredStateRepo := repository.NewInstanceDesiredStateRepository(database) instanceCommandRepo := repository.NewInstanceCommandRepository(database) instanceConfigRevisionRepo := repository.NewInstanceConfigRevisionRepository(database) + teamRepo := repository.NewTeamRepository(database) skillRepo := repository.NewSkillRepository(database) securityScanRepo := repository.NewSecurityScanRepository(database) @@ -108,6 +109,7 @@ func main() { instanceRuntimeStatusService := services.NewInstanceRuntimeStatusService(instanceRuntimeStatusRepo, instanceAgentRepo, instanceDesiredStateRepo) instanceCommandService := services.NewInstanceCommandService(instanceCommandRepo, instanceRuntimeStatusRepo, instanceDesiredStateRepo) instanceConfigRevisionService := services.NewInstanceConfigRevisionService(instanceConfigRevisionRepo) + teamService := services.NewTeamService(teamRepo, instanceService) skillService := services.NewSkillService(skillRepo, instanceRepo, instanceCommandService, objectStorageService, skillScannerClient) securityScanService := services.NewSecurityScanService(securityScanRepo, skillRepo, objectStorageService, skillScannerClient) aiGatewayService := aigateway.NewService(llmModelRepo, modelInvocationService, auditEventService, costRecordService, riskDetectionService, riskHitService, chatSessionService, chatMessageService) @@ -127,6 +129,7 @@ func main() { skillHandler := handlers.NewSkillHandler(skillService, instanceService) securityHandler := handlers.NewSecurityHandler(securityScanService) agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService, skillService) + teamHandler := handlers.NewTeamHandler(teamService) // Initialize WebSocket hub and handler wsHub := services.GetHub() @@ -135,6 +138,7 @@ func main() { // Start sync service to keep instance status in sync with K8s syncService := services.NewSyncService(instanceRepo, instanceRuntimeStatusService) syncService.Start() + teamService.Start() // Setup router r := gin.Default() @@ -224,6 +228,18 @@ func main() { adminInstances.GET("", instanceHandler.ListAllInstances) } + teams := api.Group("/teams") + teams.Use(middleware.Auth()) + teams.Use(middleware.SetUserInfo(userRepo)) + { + teams.GET("", teamHandler.ListTeams) + teams.POST("", teamHandler.CreateTeam) + teams.GET("/:id", teamHandler.GetTeam) + teams.DELETE("/:id", teamHandler.DeleteTeam) + teams.POST("/:id/tasks", teamHandler.DispatchTask) + teams.DELETE("/:id/members/:memberID", teamHandler.DeleteMember) + } + openClawConfigs := api.Group("/openclaw-configs") openClawConfigs.Use(middleware.Auth()) openClawConfigs.Use(middleware.SetUserInfo(userRepo)) @@ -405,6 +421,7 @@ func main() { // Stop background services syncService.Stop() + teamService.Stop() wsHub.Stop() instanceHandler.Shutdown() diff --git a/backend/internal/db/migrations/014_add_team_mvp.sql b/backend/internal/db/migrations/014_add_team_mvp.sql new file mode 100644 index 0000000..6bcd530 --- /dev/null +++ b/backend/internal/db/migrations/014_add_team_mvp.sql @@ -0,0 +1,90 @@ +CREATE TABLE IF NOT EXISTS teams ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'creating', + communication_mode VARCHAR(50) NOT NULL DEFAULT 'leader_only', + redis_url_secret_name VARCHAR(255) NULL, + redis_url_secret_key VARCHAR(255) NULL, + team_token_secret_name VARCHAR(255) NULL, + team_token_secret_key VARCHAR(255) NULL, + redis_events_last_id VARCHAR(128) NOT NULL DEFAULT '0-0', + shared_pvc_name VARCHAR(255) NULL, + shared_pvc_namespace VARCHAR(255) NULL, + shared_mount_path VARCHAR(255) NOT NULL DEFAULT '/team', + storage_class VARCHAR(100) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uk_teams_user_name (user_id, name), + INDEX idx_teams_user_status (user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS team_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + instance_id INT NULL, + user_id INT NOT NULL, + member_key VARCHAR(100) NOT NULL, + display_name VARCHAR(255) NOT NULL, + role VARCHAR(100) NOT NULL, + description TEXT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'creating', + current_task_id INT NULL, + progress INT NOT NULL DEFAULT 0, + last_seen_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE SET NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uk_team_members_key (team_id, member_key), + UNIQUE KEY uk_team_members_instance (team_id, instance_id), + INDEX idx_team_members_team_status (team_id, status), + INDEX idx_team_members_instance (instance_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS team_tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + target_member_id INT NOT NULL, + created_by INT NULL, + message_id VARCHAR(255) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'pending', + payload_json LONGTEXT NOT NULL, + result_json LONGTEXT NULL, + error_message TEXT NULL, + redis_stream_id VARCHAR(128) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + dispatched_at TIMESTAMP NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (target_member_id) REFERENCES team_members(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uk_team_tasks_message (team_id, message_id), + INDEX idx_team_tasks_team_status (team_id, status), + INDEX idx_team_tasks_member_status (target_member_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS team_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + member_id INT NULL, + task_id INT NULL, + message_id VARCHAR(255) NULL, + event_type VARCHAR(100) NOT NULL, + payload_json LONGTEXT NULL, + redis_stream_id VARCHAR(128) NULL, + occurred_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (member_id) REFERENCES team_members(id) ON DELETE SET NULL, + FOREIGN KEY (task_id) REFERENCES team_tasks(id) ON DELETE SET NULL, + UNIQUE KEY uk_team_events_stream_id (team_id, redis_stream_id), + INDEX idx_team_events_team_created (team_id, created_at), + INDEX idx_team_events_task (task_id), + INDEX idx_team_events_member (member_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/internal/db/migrations/015_add_team_member_description.sql b/backend/internal/db/migrations/015_add_team_member_description.sql new file mode 100644 index 0000000..fe941bd --- /dev/null +++ b/backend/internal/db/migrations/015_add_team_member_description.sql @@ -0,0 +1,15 @@ +SET @team_member_description_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'team_members' + AND COLUMN_NAME = 'description' +); +SET @team_member_description_column_sql = IF( + @team_member_description_column_exists = 0, + 'ALTER TABLE team_members ADD COLUMN description TEXT NULL AFTER role', + 'SELECT 1' +); +PREPARE team_member_description_column_stmt FROM @team_member_description_column_sql; +EXECUTE team_member_description_column_stmt; +DEALLOCATE PREPARE team_member_description_column_stmt; diff --git a/backend/internal/db/migrations/016_release_deleted_team_names.sql b/backend/internal/db/migrations/016_release_deleted_team_names.sql new file mode 100644 index 0000000..925ef20 --- /dev/null +++ b/backend/internal/db/migrations/016_release_deleted_team_names.sql @@ -0,0 +1,8 @@ +UPDATE teams +SET name = CONCAT( + LEFT(name, GREATEST(1, 255 - CHAR_LENGTH(CONCAT('__deleted_', id)))), + '__deleted_', + id +) +WHERE status = 'deleted' + AND name NOT REGEXP '__deleted_[0-9]+$'; diff --git a/backend/internal/db/migrations/017_add_team_member_runtime_state.sql b/backend/internal/db/migrations/017_add_team_member_runtime_state.sql new file mode 100644 index 0000000..d05cf25 --- /dev/null +++ b/backend/internal/db/migrations/017_add_team_member_runtime_state.sql @@ -0,0 +1,95 @@ +SET @team_member_availability_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'team_members' + AND COLUMN_NAME = 'availability' +); +SET @team_member_availability_column_sql = IF( + @team_member_availability_column_exists = 0, + 'ALTER TABLE team_members ADD COLUMN availability VARCHAR(30) NOT NULL DEFAULT ''unknown'' AFTER last_seen_at', + 'SELECT 1' +); +PREPARE team_member_availability_column_stmt FROM @team_member_availability_column_sql; +EXECUTE team_member_availability_column_stmt; +DEALLOCATE PREPARE team_member_availability_column_stmt; + +SET @team_member_runtime_status_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'team_members' + AND COLUMN_NAME = 'runtime_status' +); +SET @team_member_runtime_status_column_sql = IF( + @team_member_runtime_status_column_exists = 0, + 'ALTER TABLE team_members ADD COLUMN runtime_status VARCHAR(50) NULL AFTER availability', + 'SELECT 1' +); +PREPARE team_member_runtime_status_column_stmt FROM @team_member_runtime_status_column_sql; +EXECUTE team_member_runtime_status_column_stmt; +DEALLOCATE PREPARE team_member_runtime_status_column_stmt; + +SET @team_member_runtime_task_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'team_members' + AND COLUMN_NAME = 'runtime_task_id' +); +SET @team_member_runtime_task_column_sql = IF( + @team_member_runtime_task_column_exists = 0, + 'ALTER TABLE team_members ADD COLUMN runtime_task_id VARCHAR(255) NULL AFTER runtime_status', + 'SELECT 1' +); +PREPARE team_member_runtime_task_column_stmt FROM @team_member_runtime_task_column_sql; +EXECUTE team_member_runtime_task_column_stmt; +DEALLOCATE PREPARE team_member_runtime_task_column_stmt; + +SET @team_member_runtime_intent_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'team_members' + AND COLUMN_NAME = 'runtime_intent' +); +SET @team_member_runtime_intent_column_sql = IF( + @team_member_runtime_intent_column_exists = 0, + 'ALTER TABLE team_members ADD COLUMN runtime_intent VARCHAR(100) NULL AFTER runtime_task_id', + 'SELECT 1' +); +PREPARE team_member_runtime_intent_column_stmt FROM @team_member_runtime_intent_column_sql; +EXECUTE team_member_runtime_intent_column_stmt; +DEALLOCATE PREPARE team_member_runtime_intent_column_stmt; + +SET @team_member_blocked_reason_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'team_members' + AND COLUMN_NAME = 'blocked_reason' +); +SET @team_member_blocked_reason_column_sql = IF( + @team_member_blocked_reason_column_exists = 0, + 'ALTER TABLE team_members ADD COLUMN blocked_reason TEXT NULL AFTER runtime_intent', + 'SELECT 1' +); +PREPARE team_member_blocked_reason_column_stmt FROM @team_member_blocked_reason_column_sql; +EXECUTE team_member_blocked_reason_column_stmt; +DEALLOCATE PREPARE team_member_blocked_reason_column_stmt; + +SET @team_member_last_summary_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'team_members' + AND COLUMN_NAME = 'last_summary' +); +SET @team_member_last_summary_column_sql = IF( + @team_member_last_summary_column_exists = 0, + 'ALTER TABLE team_members ADD COLUMN last_summary TEXT NULL AFTER blocked_reason', + 'SELECT 1' +); +PREPARE team_member_last_summary_column_stmt FROM @team_member_last_summary_column_sql; +EXECUTE team_member_last_summary_column_stmt; +DEALLOCATE PREPARE team_member_last_summary_column_stmt; diff --git a/backend/internal/db/migrations/018_add_team_member_runtime_type.sql b/backend/internal/db/migrations/018_add_team_member_runtime_type.sql new file mode 100644 index 0000000..8ed59e0 --- /dev/null +++ b/backend/internal/db/migrations/018_add_team_member_runtime_type.sql @@ -0,0 +1,15 @@ +SET @team_member_runtime_type_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'team_members' + AND COLUMN_NAME = 'runtime_type' +); +SET @team_member_runtime_type_column_sql = IF( + @team_member_runtime_type_column_exists = 0, + 'ALTER TABLE team_members ADD COLUMN runtime_type VARCHAR(30) NOT NULL DEFAULT ''openclaw'' AFTER role', + 'SELECT 1' +); +PREPARE team_member_runtime_type_column_stmt FROM @team_member_runtime_type_column_sql; +EXECUTE team_member_runtime_type_column_stmt; +DEALLOCATE PREPARE team_member_runtime_type_column_stmt; diff --git a/backend/internal/handlers/team_handler.go b/backend/internal/handlers/team_handler.go new file mode 100644 index 0000000..e4a12a0 --- /dev/null +++ b/backend/internal/handlers/team_handler.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "io" + "net/http" + "strconv" + + "clawreef/internal/services" + "clawreef/internal/utils" + + "github.com/gin-gonic/gin" +) + +type TeamHandler struct { + teamService services.TeamService +} + +func NewTeamHandler(teamService services.TeamService) *TeamHandler { + return &TeamHandler{teamService: teamService} +} + +func (h *TeamHandler) CreateTeam(c *gin.Context) { + userID, _ := c.Get("userID") + var req services.CreateTeamRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + team, err := h.teamService.CreateTeam(userID.(int), req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "Team created successfully", team) +} + +func (h *TeamHandler) ListTeams(c *gin.Context) { + userID, _ := c.Get("userID") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + if page <= 0 { + page = 1 + } + if limit <= 0 { + limit = 20 + } + teams, err := h.teamService.ListTeams(userID.(int), (page-1)*limit, limit) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Teams retrieved successfully", gin.H{ + "teams": teams.Teams, + "total": teams.Total, + "page": page, + "limit": limit, + }) +} + +func (h *TeamHandler) GetTeam(c *gin.Context) { + userID, _ := c.Get("userID") + teamID, ok := parseTeamID(c) + if !ok { + return + } + team, err := h.teamService.GetTeam(userID.(int), teamID) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Team retrieved successfully", team) +} + +func (h *TeamHandler) DispatchTask(c *gin.Context) { + userID, _ := c.Get("userID") + teamID, ok := parseTeamID(c) + if !ok { + return + } + + var req services.DispatchTeamTaskRequest + if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF { + utils.ValidationError(c, err) + return + } + task, err := h.teamService.DispatchTask(userID.(int), teamID, req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "Team task dispatched successfully", task) +} + +func (h *TeamHandler) DeleteTeam(c *gin.Context) { + userID, _ := c.Get("userID") + teamID, ok := parseTeamID(c) + if !ok { + return + } + if err := h.teamService.DeleteTeam(userID.(int), teamID); err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Team deleted successfully", gin.H{"id": teamID}) +} + +func (h *TeamHandler) DeleteMember(c *gin.Context) { + userID, _ := c.Get("userID") + teamID, ok := parseTeamID(c) + if !ok { + return + } + memberID := c.Param("memberID") + if err := h.teamService.DeleteMember(userID.(int), teamID, memberID); err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "Team member deleted successfully", gin.H{"member_id": memberID}) +} + +func parseTeamID(c *gin.Context) (int, bool) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid Team ID") + return 0, false + } + return id, true +} diff --git a/backend/internal/models/team.go b/backend/internal/models/team.go new file mode 100644 index 0000000..7f5fc8d --- /dev/null +++ b/backend/internal/models/team.go @@ -0,0 +1,124 @@ +package models + +import "time" + +const ( + TeamStatusCreating = "creating" + TeamStatusRunning = "running" + TeamStatusFailed = "failed" + + TeamMemberStatusCreating = "creating" + TeamMemberStatusIdle = "idle" + TeamMemberStatusBusy = "busy" + TeamMemberStatusFailed = "failed" + TeamMemberStatusOffline = "offline" + TeamMemberStatusDeleting = "deleting" + TeamMemberStatusDeleted = "deleted" + + TeamMemberAvailabilityUnknown = "unknown" + TeamMemberAvailabilityIdle = "idle" + TeamMemberAvailabilityBusy = "busy" + TeamMemberAvailabilityBlocked = "blocked" + TeamMemberAvailabilityOffline = "offline" + + TeamStatusDeleting = "deleting" + TeamStatusDeleted = "deleted" + + TeamTaskStatusPending = "pending" + TeamTaskStatusDispatched = "dispatched" + TeamTaskStatusRunning = "running" + TeamTaskStatusSucceeded = "succeeded" + TeamTaskStatusFailed = "failed" + TeamTaskStatusStale = "stale" +) + +type Team struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + UserID int `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description,omitempty"` + Status string `db:"status" json:"status"` + CommunicationMode string `db:"communication_mode" json:"communication_mode"` + RedisURLSecretName *string `db:"redis_url_secret_name" json:"-"` + RedisURLSecretKey *string `db:"redis_url_secret_key" json:"-"` + TeamTokenSecretName *string `db:"team_token_secret_name" json:"-"` + TeamTokenSecretKey *string `db:"team_token_secret_key" json:"-"` + RedisEventsLastID string `db:"redis_events_last_id" json:"redis_events_last_id"` + SharedPVCName *string `db:"shared_pvc_name" json:"shared_pvc_name,omitempty"` + SharedPVCNamespace *string `db:"shared_pvc_namespace" json:"shared_pvc_namespace,omitempty"` + SharedMountPath string `db:"shared_mount_path" json:"shared_mount_path"` + StorageClass *string `db:"storage_class" json:"storage_class,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (Team) TableName() string { + return "teams" +} + +type TeamMember struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + TeamID int `db:"team_id" json:"team_id"` + InstanceID *int `db:"instance_id" json:"instance_id,omitempty"` + UserID int `db:"user_id" json:"user_id"` + MemberKey string `db:"member_key" json:"member_key"` + DisplayName string `db:"display_name" json:"display_name"` + Role string `db:"role" json:"role"` + RuntimeType string `db:"runtime_type" json:"runtime_type"` + Description *string `db:"description" json:"description,omitempty"` + Status string `db:"status" json:"status"` + CurrentTaskID *int `db:"current_task_id" json:"current_task_id,omitempty"` + Progress int `db:"progress" json:"progress"` + LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at,omitempty"` + Availability string `db:"availability" json:"availability"` + RuntimeStatus *string `db:"runtime_status" json:"runtime_status,omitempty"` + RuntimeTaskID *string `db:"runtime_task_id" json:"runtime_task_id,omitempty"` + RuntimeIntent *string `db:"runtime_intent" json:"runtime_intent,omitempty"` + BlockedReason *string `db:"blocked_reason" json:"blocked_reason,omitempty"` + LastSummary *string `db:"last_summary" json:"last_summary,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (TeamMember) TableName() string { + return "team_members" +} + +type TeamTask struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + TeamID int `db:"team_id" json:"team_id"` + TargetMemberID int `db:"target_member_id" json:"target_member_id"` + CreatedBy *int `db:"created_by" json:"created_by,omitempty"` + MessageID string `db:"message_id" json:"message_id"` + Status string `db:"status" json:"status"` + PayloadJSON string `db:"payload_json" json:"-"` + ResultJSON *string `db:"result_json" json:"-"` + ErrorMessage *string `db:"error_message" json:"error_message,omitempty"` + RedisStreamID *string `db:"redis_stream_id" json:"redis_stream_id,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + DispatchedAt *time.Time `db:"dispatched_at" json:"dispatched_at,omitempty"` + StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"` + FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (TeamTask) TableName() string { + return "team_tasks" +} + +type TeamEvent struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + TeamID int `db:"team_id" json:"team_id"` + MemberID *int `db:"member_id" json:"member_id,omitempty"` + TaskID *int `db:"task_id" json:"task_id,omitempty"` + MessageID *string `db:"message_id" json:"message_id,omitempty"` + EventType string `db:"event_type" json:"event_type"` + PayloadJSON *string `db:"payload_json" json:"-"` + RedisStreamID *string `db:"redis_stream_id" json:"redis_stream_id,omitempty"` + OccurredAt *time.Time `db:"occurred_at" json:"occurred_at,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (TeamEvent) TableName() string { + return "team_events" +} diff --git a/backend/internal/repository/instance_repository.go b/backend/internal/repository/instance_repository.go index 12bc667..1102afd 100644 --- a/backend/internal/repository/instance_repository.go +++ b/backend/internal/repository/instance_repository.go @@ -105,7 +105,7 @@ func (r *instanceRepository) CountAll() (int, error) { // GetByUserID gets instances by user ID with pagination func (r *instanceRepository) GetByUserID(userID int, offset, limit int) ([]models.Instance, error) { var instances []models.Instance - err := r.sess.Collection("instances").Find(db.Cond{"user_id": userID}).Offset(offset).Limit(limit).All(&instances) + err := r.sess.Collection("instances").Find(db.Cond{"user_id": userID}).OrderBy("-created_at", "-id").Offset(offset).Limit(limit).All(&instances) if err != nil { return nil, fmt.Errorf("failed to get instances: %w", err) } diff --git a/backend/internal/repository/team_repository.go b/backend/internal/repository/team_repository.go new file mode 100644 index 0000000..b536ca5 --- /dev/null +++ b/backend/internal/repository/team_repository.go @@ -0,0 +1,290 @@ +package repository + +import ( + "fmt" + "strings" + "time" + + "clawreef/internal/models" + + "github.com/upper/db/v4" +) + +type TeamRepository interface { + CreateTeam(team *models.Team) error + UpdateTeam(team *models.Team) error + GetTeamByID(id int) (*models.Team, error) + GetTeamByUserIDAndName(userID int, name string) (*models.Team, error) + ExistsByUserIDAndName(userID int, name string) (bool, error) + ListTeamsByUserID(userID int, offset, limit int) ([]models.Team, error) + ListActiveTeams() ([]models.Team, error) + CountTeamsByUserID(userID int) (int, error) + + CreateMember(member *models.TeamMember) error + UpdateMember(member *models.TeamMember) error + GetMemberByID(id int) (*models.TeamMember, error) + GetMemberByTeamKey(teamID int, memberKey string) (*models.TeamMember, error) + ListMembersByTeamID(teamID int) ([]models.TeamMember, error) + + CreateTask(task *models.TeamTask) error + UpdateTask(task *models.TeamTask) error + GetTaskByID(id int) (*models.TeamTask, error) + GetTaskByMessageID(teamID int, messageID string) (*models.TeamTask, error) + ListTasksByTeamID(teamID int, limit int) ([]models.TeamTask, error) + ListStaleCandidateTasks(cutoff time.Time, limit int) ([]models.TeamTask, error) + + CreateEvent(event *models.TeamEvent) error + EventExistsByStreamID(teamID int, streamID string) (bool, error) + ListEventsByTeamID(teamID int, limit int) ([]models.TeamEvent, error) +} + +type teamRepository struct { + sess db.Session +} + +func NewTeamRepository(sess db.Session) TeamRepository { + return &teamRepository{sess: sess} +} + +func (r *teamRepository) CreateTeam(team *models.Team) error { + ensureTimestamps(&team.CreatedAt, &team.UpdatedAt) + res, err := r.sess.Collection("teams").Insert(team) + if err != nil { + if strings.Contains(err.Error(), "Duplicate entry") && strings.Contains(err.Error(), "uk_teams_user_name") { + return fmt.Errorf("team name already exists") + } + return fmt.Errorf("failed to create team: %w", err) + } + if id, ok := res.ID().(int64); ok { + team.ID = int(id) + } + return nil +} + +func (r *teamRepository) UpdateTeam(team *models.Team) error { + if team.UpdatedAt.IsZero() { + team.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("teams").Find(db.Cond{"id": team.ID}).Update(team); err != nil { + return fmt.Errorf("failed to update team: %w", err) + } + return nil +} + +func (r *teamRepository) GetTeamByID(id int) (*models.Team, error) { + var team models.Team + if err := r.sess.Collection("teams").Find(db.Cond{"id": id}).One(&team); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get team: %w", err) + } + return &team, nil +} + +func (r *teamRepository) GetTeamByUserIDAndName(userID int, name string) (*models.Team, error) { + var team models.Team + if err := r.sess.Collection("teams").Find(db.Cond{"user_id": userID, "name": name}).One(&team); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get team by name: %w", err) + } + return &team, nil +} + +func (r *teamRepository) ExistsByUserIDAndName(userID int, name string) (bool, error) { + count, err := r.sess.Collection("teams").Find(db.Cond{"user_id": userID, "name": name}).Count() + if err != nil { + return false, fmt.Errorf("failed to check team name: %w", err) + } + return count > 0, nil +} + +func (r *teamRepository) ListTeamsByUserID(userID int, offset, limit int) ([]models.Team, error) { + if limit <= 0 { + limit = 20 + } + var teams []models.Team + if err := r.sess.Collection("teams").Find(db.Cond{"user_id": userID}).OrderBy("-created_at", "-id").Offset(offset).Limit(limit).All(&teams); err != nil { + return nil, fmt.Errorf("failed to list teams: %w", err) + } + return teams, nil +} + +func (r *teamRepository) ListActiveTeams() ([]models.Team, error) { + var teams []models.Team + if err := r.sess.Collection("teams").Find(db.Cond{"status IN": []string{models.TeamStatusCreating, models.TeamStatusRunning}}).All(&teams); err != nil { + return nil, fmt.Errorf("failed to list active teams: %w", err) + } + return teams, nil +} + +func (r *teamRepository) CountTeamsByUserID(userID int) (int, error) { + count, err := r.sess.Collection("teams").Find(db.Cond{"user_id": userID}).Count() + if err != nil { + return 0, fmt.Errorf("failed to count teams: %w", err) + } + return int(count), nil +} + +func (r *teamRepository) CreateMember(member *models.TeamMember) error { + ensureTimestamps(&member.CreatedAt, &member.UpdatedAt) + res, err := r.sess.Collection("team_members").Insert(member) + if err != nil { + return fmt.Errorf("failed to create team member: %w", err) + } + if id, ok := res.ID().(int64); ok { + member.ID = int(id) + } + return nil +} + +func (r *teamRepository) UpdateMember(member *models.TeamMember) error { + if member.UpdatedAt.IsZero() { + member.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("team_members").Find(db.Cond{"id": member.ID}).Update(member); err != nil { + return fmt.Errorf("failed to update team member: %w", err) + } + return nil +} + +func (r *teamRepository) GetMemberByID(id int) (*models.TeamMember, error) { + var member models.TeamMember + if err := r.sess.Collection("team_members").Find(db.Cond{"id": id}).One(&member); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get team member: %w", err) + } + return &member, nil +} + +func (r *teamRepository) GetMemberByTeamKey(teamID int, memberKey string) (*models.TeamMember, error) { + var member models.TeamMember + if err := r.sess.Collection("team_members").Find(db.Cond{"team_id": teamID, "member_key": memberKey}).One(&member); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get team member by key: %w", err) + } + return &member, nil +} + +func (r *teamRepository) ListMembersByTeamID(teamID int) ([]models.TeamMember, error) { + var members []models.TeamMember + if err := r.sess.Collection("team_members").Find(db.Cond{"team_id": teamID}).OrderBy("id").All(&members); err != nil { + return nil, fmt.Errorf("failed to list team members: %w", err) + } + return members, nil +} + +func (r *teamRepository) CreateTask(task *models.TeamTask) error { + ensureTimestamps(&task.CreatedAt, &task.UpdatedAt) + res, err := r.sess.Collection("team_tasks").Insert(task) + if err != nil { + return fmt.Errorf("failed to create team task: %w", err) + } + if id, ok := res.ID().(int64); ok { + task.ID = int(id) + } + return nil +} + +func (r *teamRepository) UpdateTask(task *models.TeamTask) error { + if task.UpdatedAt.IsZero() { + task.UpdatedAt = time.Now().UTC() + } + if err := r.sess.Collection("team_tasks").Find(db.Cond{"id": task.ID}).Update(task); err != nil { + return fmt.Errorf("failed to update team task: %w", err) + } + return nil +} + +func (r *teamRepository) GetTaskByID(id int) (*models.TeamTask, error) { + var task models.TeamTask + if err := r.sess.Collection("team_tasks").Find(db.Cond{"id": id}).One(&task); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get team task: %w", err) + } + return &task, nil +} + +func (r *teamRepository) GetTaskByMessageID(teamID int, messageID string) (*models.TeamTask, error) { + var task models.TeamTask + if err := r.sess.Collection("team_tasks").Find(db.Cond{"team_id": teamID, "message_id": messageID}).One(&task); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get team task by message id: %w", err) + } + return &task, nil +} + +func (r *teamRepository) ListTasksByTeamID(teamID int, limit int) ([]models.TeamTask, error) { + if limit <= 0 { + limit = 20 + } + var tasks []models.TeamTask + if err := r.sess.Collection("team_tasks").Find(db.Cond{"team_id": teamID}).OrderBy("-created_at", "-id").Limit(limit).All(&tasks); err != nil { + return nil, fmt.Errorf("failed to list team tasks: %w", err) + } + return tasks, nil +} + +func (r *teamRepository) ListStaleCandidateTasks(cutoff time.Time, limit int) ([]models.TeamTask, error) { + if limit <= 0 { + limit = 100 + } + var tasks []models.TeamTask + statuses := []string{ + models.TeamTaskStatusDispatched, + models.TeamTaskStatusRunning, + } + if err := r.sess.Collection("team_tasks").Find(db.Cond{ + "status IN": statuses, + "updated_at <": cutoff, + }).OrderBy("updated_at", "id").Limit(limit).All(&tasks); err != nil { + return nil, fmt.Errorf("failed to list stale candidate team tasks: %w", err) + } + return tasks, nil +} + +func (r *teamRepository) CreateEvent(event *models.TeamEvent) error { + if event.CreatedAt.IsZero() { + event.CreatedAt = time.Now().UTC() + } + res, err := r.sess.Collection("team_events").Insert(event) + if err != nil { + return fmt.Errorf("failed to create team event: %w", err) + } + if id, ok := res.ID().(int64); ok { + event.ID = int(id) + } + return nil +} + +func (r *teamRepository) EventExistsByStreamID(teamID int, streamID string) (bool, error) { + if streamID == "" { + return false, nil + } + count, err := r.sess.Collection("team_events").Find(db.Cond{"team_id": teamID, "redis_stream_id": streamID}).Count() + if err != nil { + return false, fmt.Errorf("failed to check team event stream id: %w", err) + } + return count > 0, nil +} + +func (r *teamRepository) ListEventsByTeamID(teamID int, limit int) ([]models.TeamEvent, error) { + if limit <= 0 { + limit = 50 + } + var events []models.TeamEvent + if err := r.sess.Collection("team_events").Find(db.Cond{"team_id": teamID}).OrderBy("-created_at", "-id").Limit(limit).All(&events); err != nil { + return nil, fmt.Errorf("failed to list team events: %w", err) + } + return events, nil +} diff --git a/backend/internal/services/instance_service.go b/backend/internal/services/instance_service.go index 6a664d5..d1f97d4 100644 --- a/backend/internal/services/instance_service.go +++ b/backend/internal/services/instance_service.go @@ -21,6 +21,7 @@ import ( // InstanceService defines the interface for instance operations type InstanceService interface { Create(userID int, req CreateInstanceRequest) (*models.Instance, error) + ValidateCreateRequests(userID int, requests []CreateInstanceRequest) error GetByID(id int) (*models.Instance, error) GetByUserID(userID int, offset, limit int) ([]models.Instance, int, error) GetAllInstances(offset, limit int) ([]models.Instance, int, error) @@ -33,6 +34,98 @@ type InstanceService interface { ForceSyncInstance(instanceID int) error } +func (s *instanceService) ValidateCreateRequests(userID int, requests []CreateInstanceRequest) error { + if len(requests) == 0 { + return nil + } + for idx := range requests { + requests[idx].Name = strings.TrimSpace(requests[idx].Name) + if requests[idx].Name == "" { + return fmt.Errorf("instance name is required") + } + environmentOverrides, err := normalizeEnvironmentOverrides(requests[idx].EnvironmentOverrides) + if err != nil { + return err + } + if _, err := marshalEnvironmentOverrides(environmentOverrides); err != nil { + return err + } + } + + quota, err := s.quotaRepo.GetByUserID(userID) + if err != nil { + return fmt.Errorf("failed to get user quota: %w", err) + } + if quota == nil { + return fmt.Errorf("user quota not found") + } + + currentCount, err := s.instanceRepo.CountByUserID(userID) + if err != nil { + return fmt.Errorf("failed to count instances: %w", err) + } + if currentCount+len(requests) > quota.MaxInstances { + return fmt.Errorf("instance limit reached: %d/%d", currentCount+len(requests), quota.MaxInstances) + } + + existingInstances, err := s.instanceRepo.GetByUserID(userID, 0, 1000) + if err != nil { + return fmt.Errorf("failed to list user instances for quota validation: %w", err) + } + + currentCPU := 0.0 + currentMemory := 0 + currentStorage := 0 + currentGPU := 0 + existingNames := map[string]struct{}{} + for _, existing := range existingInstances { + currentCPU += existing.CPUCores + currentMemory += existing.MemoryGB + currentStorage += existing.DiskGB + if existing.GPUEnabled { + currentGPU += existing.GPUCount + } + existingNames[strings.TrimSpace(strings.ToLower(existing.Name))] = struct{}{} + } + + requestedCPU := 0.0 + requestedMemory := 0 + requestedStorage := 0 + requestedGPU := 0 + requestNames := map[string]struct{}{} + for _, req := range requests { + normalizedName := strings.TrimSpace(strings.ToLower(req.Name)) + if _, exists := existingNames[normalizedName]; exists { + return fmt.Errorf("instance name already exists") + } + if _, exists := requestNames[normalizedName]; exists { + return fmt.Errorf("instance name already exists") + } + requestNames[normalizedName] = struct{}{} + requestedCPU += req.CPUCores + requestedMemory += req.MemoryGB + requestedStorage += req.DiskGB + if req.GPUEnabled { + requestedGPU += req.GPUCount + } + } + + if currentCPU+requestedCPU > quota.MaxCPUCores { + return fmt.Errorf("CPU cores exceed quota: current %v, requested %v, max %v", currentCPU, requestedCPU, quota.MaxCPUCores) + } + if currentMemory+requestedMemory > quota.MaxMemoryGB { + return fmt.Errorf("memory exceed quota: current %dGB, requested %dGB, max %dGB", currentMemory, requestedMemory, quota.MaxMemoryGB) + } + if currentStorage+requestedStorage > quota.MaxStorageGB { + return fmt.Errorf("storage exceed quota: current %dGB, requested %dGB, max %dGB", currentStorage, requestedStorage, quota.MaxStorageGB) + } + if currentGPU+requestedGPU > quota.MaxGPUCount { + return fmt.Errorf("GPU count exceed quota: current %d, requested %d, max %d", currentGPU, requestedGPU, quota.MaxGPUCount) + } + + return nil +} + // CreateInstanceRequest holds data for creating an instance type CreateInstanceRequest struct { Name string `json:"name" validate:"required,min=3,max=50"` @@ -50,6 +143,16 @@ type CreateInstanceRequest struct { EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"` StorageClass string `json:"storage_class"` OpenClawConfigPlan *OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"` + Team *TeamInstanceConfig `json:"-"` +} + +type TeamInstanceConfig struct { + Environment map[string]string + SecretName string + SharedPVCName string + SharedMountPath string + ConfigMapName string + ConfigMountPath string } // UpdateInstanceRequest holds data for updating an instance @@ -264,6 +367,9 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models s.instanceRepo.Delete(instance.ID) return nil, fmt.Errorf("failed to resolve instance environment: %w", err) } + if req.Team != nil { + extraEnv = mergeEnvMaps(extraEnv, req.Team.Environment) + } var bootstrapSnapshot *models.OpenClawInjectionSnapshot var bootstrapSecretName string @@ -318,23 +424,50 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models // Create Pod shmSizeGB := popSHMSizeGB(extraEnv) + envFromSecretNames := []string{bootstrapSecretName} + extraPVCMounts := []k8s.PVCMount{} + configMapFileMounts := []k8s.ConfigMapFileMount{} + if req.Team != nil { + if strings.TrimSpace(req.Team.SecretName) != "" { + envFromSecretNames = append(envFromSecretNames, strings.TrimSpace(req.Team.SecretName)) + } + if strings.TrimSpace(req.Team.SharedPVCName) != "" && strings.TrimSpace(req.Team.SharedMountPath) != "" { + extraPVCMounts = append(extraPVCMounts, k8s.PVCMount{ + Name: "team-shared", + ClaimName: strings.TrimSpace(req.Team.SharedPVCName), + MountPath: strings.TrimSpace(req.Team.SharedMountPath), + }) + } + if strings.TrimSpace(req.Team.ConfigMapName) != "" && strings.TrimSpace(req.Team.ConfigMountPath) != "" { + configMapFileMounts = append(configMapFileMounts, k8s.ConfigMapFileMount{ + Name: "team-config", + ConfigMapName: strings.TrimSpace(req.Team.ConfigMapName), + Key: "team.json", + MountPath: strings.TrimSpace(req.Team.ConfigMountPath), + ReadOnly: true, + }) + } + } + podConfig := k8s.PodConfig{ - InstanceID: instance.ID, - InstanceName: instance.Name, - UserID: userID, - Type: instance.Type, - CPUCores: instance.CPUCores, - MemoryGB: instance.MemoryGB, - GPUEnabled: instance.GPUEnabled, - GPUCount: instance.GPUCount, - Image: runtimeConfig.Image, - MountPath: runtimeConfig.MountPath, - ContainerPort: runtimeConfig.Port, - ImagePullPolicy: corev1.PullPolicy(defaultImagePullPolicy()), - ExtraEnv: extraEnv, - EnvFromSecretNames: []string{bootstrapSecretName}, - SHMSizeGB: shmSizeGB, - SecurityMode: s.securityModeForInstance(instance.Type), + InstanceID: instance.ID, + InstanceName: instance.Name, + UserID: userID, + Type: instance.Type, + CPUCores: instance.CPUCores, + MemoryGB: instance.MemoryGB, + GPUEnabled: instance.GPUEnabled, + GPUCount: instance.GPUCount, + Image: runtimeConfig.Image, + MountPath: runtimeConfig.MountPath, + ContainerPort: runtimeConfig.Port, + ImagePullPolicy: corev1.PullPolicy(defaultImagePullPolicy()), + ExtraEnv: extraEnv, + EnvFromSecretNames: envFromSecretNames, + ExtraPVCMounts: extraPVCMounts, + ConfigMapFileMounts: configMapFileMounts, + SHMSizeGB: shmSizeGB, + SecurityMode: s.securityModeForInstance(instance.Type), } pod, err := s.podService.CreatePod(ctx, podConfig) diff --git a/backend/internal/services/k8s/client.go b/backend/internal/services/k8s/client.go index 2b6b47f..4ae60b9 100644 --- a/backend/internal/services/k8s/client.go +++ b/backend/internal/services/k8s/client.go @@ -240,6 +240,21 @@ func (c *Client) GetPVCName(instanceID int) string { return sanitizeK8sName(fmt.Sprintf("clawreef-%d-pvc", instanceID)) } +// GetTeamSharedPVCName returns the PVC name used for a Team shared workspace. +func (c *Client) GetTeamSharedPVCName(teamID int) string { + return sanitizeK8sName(fmt.Sprintf("clawreef-team-%d-shared", teamID)) +} + +// GetTeamSecretName returns the Secret name used for Team Redis URL and token env. +func (c *Client) GetTeamSecretName(teamID int) string { + return sanitizeK8sName(fmt.Sprintf("clawreef-team-%d-bus", teamID)) +} + +// GetTeamConfigMapName returns the ConfigMap name used for Team roster/config. +func (c *Client) GetTeamConfigMapName(teamID int) string { + return sanitizeK8sName(fmt.Sprintf("clawreef-team-%d-config", teamID)) +} + // GetServiceName returns the service name for an instance func (c *Client) GetServiceName(instanceID int, instanceName string) string { return sanitizeK8sName(fmt.Sprintf("clawreef-%d-%s-svc", instanceID, instanceName)) diff --git a/backend/internal/services/k8s/configmap_service.go b/backend/internal/services/k8s/configmap_service.go new file mode 100644 index 0000000..28c71bb --- /dev/null +++ b/backend/internal/services/k8s/configmap_service.go @@ -0,0 +1,83 @@ +package k8s + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ConfigMapService handles Kubernetes ConfigMap writes used for non-sensitive runtime config. +type ConfigMapService struct { + client *Client + namespaceService *NamespaceService +} + +// NewConfigMapService creates a new ConfigMap service. +func NewConfigMapService() *ConfigMapService { + return &ConfigMapService{ + client: globalClient, + namespaceService: NewNamespaceService(), + } +} + +// UpsertConfigMap creates or updates a ConfigMap in the user's namespace. +func (s *ConfigMapService) UpsertConfigMap(ctx context.Context, userID int, name string, data map[string]string, labels map[string]string) error { + if s.client == nil || s.client.Clientset == nil { + return fmt.Errorf("k8s client not initialized") + } + + if _, err := s.namespaceService.EnsureNamespace(ctx, userID); err != nil { + return fmt.Errorf("failed to ensure namespace: %w", err) + } + + namespace := s.client.GetNamespace(userID) + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Data: data, + } + + existing, err := s.client.Clientset.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{}) + if err == nil && existing != nil { + existing.Data = data + if existing.Labels == nil { + existing.Labels = map[string]string{} + } + for key, value := range labels { + existing.Labels[key] = value + } + if _, err := s.client.Clientset.CoreV1().ConfigMaps(namespace).Update(ctx, existing, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update configmap %s/%s: %w", namespace, name, err) + } + return nil + } + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to inspect configmap %s/%s: %w", namespace, name, err) + } + + if _, err := s.client.Clientset.CoreV1().ConfigMaps(namespace).Create(ctx, configMap, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("failed to create configmap %s/%s: %w", namespace, name, err) + } + return nil +} + +// DeleteConfigMap deletes a ConfigMap from the user's namespace. Missing maps are treated as already deleted. +func (s *ConfigMapService) DeleteConfigMap(ctx context.Context, userID int, name string) error { + if s.client == nil || s.client.Clientset == nil { + return fmt.Errorf("k8s client not initialized") + } + namespace := s.client.GetNamespace(userID) + if err := s.client.Clientset.CoreV1().ConfigMaps(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to delete configmap %s/%s: %w", namespace, name, err) + } + return nil +} diff --git a/backend/internal/services/k8s/pod_service.go b/backend/internal/services/k8s/pod_service.go index 130da4b..eeeb636 100644 --- a/backend/internal/services/k8s/pod_service.go +++ b/backend/internal/services/k8s/pod_service.go @@ -42,22 +42,39 @@ func (s *PodService) GetClient() *Client { // PodConfig holds configuration for creating a pod type PodConfig struct { - InstanceID int - InstanceName string - UserID int - Type string - CPUCores float64 - MemoryGB int - GPUEnabled bool - GPUCount int - Image string - MountPath string - ContainerPort int32 - ImagePullPolicy corev1.PullPolicy - ExtraEnv map[string]string - EnvFromSecretNames []string - SHMSizeGB int - SecurityMode PodSecurityMode + InstanceID int + InstanceName string + UserID int + Type string + CPUCores float64 + MemoryGB int + GPUEnabled bool + GPUCount int + Image string + MountPath string + ContainerPort int32 + ImagePullPolicy corev1.PullPolicy + ExtraEnv map[string]string + EnvFromSecretNames []string + ExtraPVCMounts []PVCMount + ConfigMapFileMounts []ConfigMapFileMount + SHMSizeGB int + SecurityMode PodSecurityMode +} + +type PVCMount struct { + Name string + ClaimName string + MountPath string + ReadOnly bool +} + +type ConfigMapFileMount struct { + Name string + ConfigMapName string + Key string + MountPath string + ReadOnly bool } // CreatePod creates a new pod for an instance @@ -205,6 +222,52 @@ func (s *PodService) CreatePod(ctx context.Context, config PodConfig) (*corev1.P }) } + for _, mount := range config.ExtraPVCMounts { + if mount.Name == "" || mount.ClaimName == "" || mount.MountPath == "" { + continue + } + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{ + Name: mount.Name, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: mount.ClaimName, + ReadOnly: mount.ReadOnly, + }, + }, + }) + pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{ + Name: mount.Name, + MountPath: mount.MountPath, + ReadOnly: mount.ReadOnly, + }) + } + + for _, mount := range config.ConfigMapFileMounts { + if mount.Name == "" || mount.ConfigMapName == "" || mount.Key == "" || mount.MountPath == "" { + continue + } + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{ + Name: mount.Name, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: mount.ConfigMapName}, + Items: []corev1.KeyToPath{ + { + Key: mount.Key, + Path: mount.Key, + }, + }, + }, + }, + }) + pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{ + Name: mount.Name, + MountPath: mount.MountPath, + SubPath: mount.Key, + ReadOnly: true, + }) + } + if config.SHMSizeGB > 0 { shmLimit := resource.MustParse(fmt.Sprintf("%dGi", config.SHMSizeGB)) pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{ diff --git a/backend/internal/services/k8s/pod_service_test.go b/backend/internal/services/k8s/pod_service_test.go index e02e9ca..7177863 100644 --- a/backend/internal/services/k8s/pod_service_test.go +++ b/backend/internal/services/k8s/pod_service_test.go @@ -76,3 +76,64 @@ func TestCreatePodAppliesSecurityModes(t *testing.T) { t.Fatalf("expected data and shm volumes, got %d", len(pod.Spec.Volumes)) } } + +func TestCreatePodAppliesExtraPVCMountsAndSecretEnv(t *testing.T) { + previousClient := globalClient + t.Cleanup(func() { + globalClient = previousClient + }) + + globalClient = &Client{ + Clientset: fake.NewSimpleClientset(), + Namespace: "clawreef", + StorageClass: "standard", + } + + service := NewPodService() + pod, err := service.CreatePod(context.Background(), PodConfig{ + InstanceID: 43, + InstanceName: "openclaw-team", + UserID: 7, + Type: "openclaw", + CPUCores: 1, + MemoryGB: 2, + Image: "openclaw:test", + MountPath: "/config", + ContainerPort: 3001, + EnvFromSecretNames: []string{"clawreef-team-1-bus"}, + ExtraPVCMounts: []PVCMount{ + {Name: "team-shared", ClaimName: "clawreef-team-1-shared", MountPath: "/team"}, + }, + ConfigMapFileMounts: []ConfigMapFileMount{ + {Name: "team-config", ConfigMapName: "clawreef-team-1-config", Key: "team.json", MountPath: "/team/team.json", ReadOnly: true}, + }, + }) + if err != nil { + t.Fatalf("CreatePod returned error: %v", err) + } + + container := pod.Spec.Containers[0] + if len(container.EnvFrom) != 1 || container.EnvFrom[0].SecretRef == nil || container.EnvFrom[0].SecretRef.Name != "clawreef-team-1-bus" { + t.Fatalf("expected Team secret envFrom, got %#v", container.EnvFrom) + } + + foundMount := false + for _, mount := range container.VolumeMounts { + if mount.Name == "team-shared" && mount.MountPath == "/team" { + foundMount = true + } + } + if !foundMount { + t.Fatalf("expected /team shared PVC mount, got %#v", container.VolumeMounts) + } + + foundConfig := false + for _, mount := range container.VolumeMounts { + if mount.Name == "team-config" && mount.MountPath == "/team/team.json" && mount.SubPath == "team.json" && mount.ReadOnly { + foundConfig = true + } + } + if !foundConfig { + t.Fatalf("expected /team/team.json ConfigMap file mount, got %#v", container.VolumeMounts) + } +} diff --git a/backend/internal/services/k8s/pvc_service.go b/backend/internal/services/k8s/pvc_service.go index a3a9cd2..7df7f46 100644 --- a/backend/internal/services/k8s/pvc_service.go +++ b/backend/internal/services/k8s/pvc_service.go @@ -115,6 +115,189 @@ func (s *PVCService) CreatePVC(ctx context.Context, userID, instanceID int, stor return createdPVC, nil } +// CreateTeamSharedPVC creates the RWX PVC mounted by every member of a Team. +func (s *PVCService) CreateTeamSharedPVC(ctx context.Context, userID, teamID, storageSizeGB int, storageClass string) (*corev1.PersistentVolumeClaim, error) { + if s.client == nil { + return nil, fmt.Errorf("k8s client not initialized") + } + if storageSizeGB <= 0 { + storageSizeGB = 10 + } + + if _, err := s.namespaceService.EnsureNamespace(ctx, userID); err != nil { + return nil, fmt.Errorf("failed to ensure namespace: %w", err) + } + + pvcName := s.client.GetTeamSharedPVCName(teamID) + namespace := s.client.GetNamespace(userID) + if storageClass == "" { + storageClass = s.client.StorageClass + } + storageSize := resource.MustParse(fmt.Sprintf("%dGi", storageSizeGB)) + + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: namespace, + Labels: map[string]string{ + "app": "clawreef", + "team-id": fmt.Sprintf("%d", teamID), + "managed-by": "clawreef", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteMany, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: storageSize, + }, + }, + StorageClassName: &storageClass, + }, + } + + createdPVC, err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Create(ctx, pvc, metav1.CreateOptions{}) + if err != nil { + if errors.IsAlreadyExists(err) { + existingPVC, getErr := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{}) + if getErr == nil && existingPVC != nil && existingPVC.Labels["team-id"] == fmt.Sprintf("%d", teamID) { + if existingPVC.Status.Phase != corev1.ClaimBound { + go s.monitorTeamSharedPVCBinding(context.Background(), namespace, pvcName, userID, teamID, storageSizeGB, storageClass, 15*time.Second) + } + return existingPVC, nil + } + } + return nil, fmt.Errorf("failed to create Team shared PVC %s: %w", pvcName, err) + } + + go s.monitorTeamSharedPVCBinding(context.Background(), namespace, pvcName, userID, teamID, storageSizeGB, storageClass, 15*time.Second) + return createdPVC, nil +} + +func (s *PVCService) monitorTeamSharedPVCBinding(ctx context.Context, namespace, pvcName string, userID, teamID, storageSizeGB int, storageClass string, timeout time.Duration) { + if _, err := s.waitForTeamSharedPVCBinding(ctx, namespace, pvcName, userID, teamID, storageSizeGB, storageClass, timeout); err != nil { + fmt.Printf("Async Team shared PVC binding monitor failed for %s: %v\n", pvcName, err) + } +} + +func (s *PVCService) waitForTeamSharedPVCBinding(ctx context.Context, namespace, pvcName string, userID, teamID, storageSizeGB int, storageClass string, timeout time.Duration) (*corev1.PersistentVolumeClaim, error) { + pvc, err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get Team shared PVC %s: %w", pvcName, err) + } + if pvc.Status.Phase == corev1.ClaimBound { + return pvc, nil + } + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeoutChan := time.After(timeout) + + for { + select { + case <-timeoutChan: + fmt.Printf("Team shared PVC %s binding timeout, creating hostPath RWX PV manually\n", pvcName) + return s.createPVForTeamSharedPVC(ctx, namespace, pvcName, userID, teamID, storageSizeGB, storageClass) + case <-ticker.C: + pvc, err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get Team shared PVC %s during wait: %w", pvcName, err) + } + if pvc.Status.Phase == corev1.ClaimBound { + fmt.Printf("Team shared PVC %s bound successfully to %s\n", pvcName, pvc.Spec.VolumeName) + return pvc, nil + } + fmt.Printf("Waiting for Team shared PVC %s binding, current status: %s\n", pvcName, pvc.Status.Phase) + } + } +} + +func (s *PVCService) createPVForTeamSharedPVC(ctx context.Context, namespace, pvcName string, userID, teamID, storageSizeGB int, storageClass string) (*corev1.PersistentVolumeClaim, error) { + pvName := fmt.Sprintf("clawreef-pv-user-%d-team-%d-shared", userID, teamID) + hostPathPrefix := "/data/clawreef" + if s.client != nil && s.client.HostPathPrefix != "" { + hostPathPrefix = s.client.HostPathPrefix + } + hostPath := fmt.Sprintf("%s/user-%d/team-%d-shared", hostPathPrefix, userID, teamID) + + existingPV, err := s.client.Clientset.CoreV1().PersistentVolumes().Get(ctx, pvName, metav1.GetOptions{}) + if err == nil && existingPV != nil { + if existingPV.Status.Phase == corev1.VolumeReleased { + if deleteErr := s.client.Clientset.CoreV1().PersistentVolumes().Delete(ctx, pvName, metav1.DeleteOptions{}); deleteErr != nil && !errors.IsNotFound(deleteErr) { + return nil, fmt.Errorf("failed to delete released Team shared PV %s: %w", pvName, deleteErr) + } + time.Sleep(3 * time.Second) + } else if existingPV.Spec.ClaimRef != nil && + existingPV.Spec.ClaimRef.Namespace == namespace && + existingPV.Spec.ClaimRef.Name == pvcName { + time.Sleep(2 * time.Second) + return s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{}) + } else if existingPV.Spec.ClaimRef != nil { + return nil, fmt.Errorf("Team shared PV %s already belongs to %s/%s", pvName, existingPV.Spec.ClaimRef.Namespace, existingPV.Spec.ClaimRef.Name) + } + } + + pvc, err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get Team shared PVC %s for UID: %w", pvcName, err) + } + storageSize := resource.MustParse(fmt.Sprintf("%dGi", storageSizeGB)) + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvName, + Labels: map[string]string{ + "app": "clawreef", + "user-id": fmt.Sprintf("%d", userID), + "team-id": fmt.Sprintf("%d", teamID), + "managed-by": "clawreef", + }, + }, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: storageSize, + }, + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteMany, + }, + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain, + StorageClassName: storageClass, + PersistentVolumeSource: corev1.PersistentVolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: hostPath, + Type: func() *corev1.HostPathType { + t := corev1.HostPathDirectoryOrCreate + return &t + }(), + }, + }, + ClaimRef: &corev1.ObjectReference{ + Kind: "PersistentVolumeClaim", + APIVersion: "v1", + Namespace: namespace, + Name: pvcName, + UID: pvc.UID, + ResourceVersion: pvc.ResourceVersion, + }, + }, + } + if _, err := s.client.Clientset.CoreV1().PersistentVolumes().Create(ctx, pv, metav1.CreateOptions{}); err != nil && !errors.IsAlreadyExists(err) { + return nil, fmt.Errorf("failed to create Team shared PV %s: %w", pvName, err) + } + + time.Sleep(3 * time.Second) + pvc, err = s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get Team shared PVC after PV creation: %w", err) + } + if pvc.Status.Phase != corev1.ClaimBound { + return nil, fmt.Errorf("Team shared PVC %s is still not bound after PV creation, status: %s", pvcName, pvc.Status.Phase) + } + fmt.Printf("Team shared PVC %s successfully bound to PV %s\n", pvcName, pvName) + return pvc, nil +} + func (s *PVCService) monitorPVCBinding(ctx context.Context, namespace, pvcName string, userID, instanceID, storageSizeGB int, storageClass string, timeout time.Duration) { if _, err := s.waitForPVCBinding(ctx, namespace, pvcName, userID, instanceID, storageSizeGB, storageClass, timeout); err != nil { fmt.Printf("Async PVC binding monitor failed for %s: %v\n", pvcName, err) @@ -375,6 +558,31 @@ func (s *PVCService) DeletePVC(ctx context.Context, userID, instanceID int) erro return nil } +// DeleteTeamSharedPVC deletes a Team shared PVC and the predictable hostPath PV +// used by the single-node manual storage fallback. It does not delete an +// arbitrary bound PV because that may belong to a real RWX provisioner. +func (s *PVCService) DeleteTeamSharedPVC(ctx context.Context, userID, teamID int) error { + if s.client == nil { + return fmt.Errorf("k8s client not initialized") + } + + pvcName := s.client.GetTeamSharedPVCName(teamID) + namespace := s.client.GetNamespace(userID) + pvName := fmt.Sprintf("clawreef-pv-user-%d-team-%d-shared", userID, teamID) + + if err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Delete(ctx, pvcName, metav1.DeleteOptions{}); err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete Team shared PVC %s/%s: %w", namespace, pvcName, err) + } + } + if err := s.client.Clientset.CoreV1().PersistentVolumes().Delete(ctx, pvName, metav1.DeleteOptions{}); err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete Team shared PV %s: %w", pvName, err) + } + } + return nil +} + // PVCExists checks if a PVC exists func (s *PVCService) PVCExists(ctx context.Context, userID, instanceID int) (bool, error) { _, err := s.GetPVC(ctx, userID, instanceID) diff --git a/backend/internal/services/k8s/secret_service.go b/backend/internal/services/k8s/secret_service.go index 8a943b7..8a93c33 100644 --- a/backend/internal/services/k8s/secret_service.go +++ b/backend/internal/services/k8s/secret_service.go @@ -91,3 +91,18 @@ func (s *SecretService) UpsertSecret(ctx context.Context, userID int, name strin } return nil } + +// DeleteSecret deletes a Secret from the user's namespace. Missing secrets are treated as already deleted. +func (s *SecretService) DeleteSecret(ctx context.Context, userID int, name string) error { + if s.client == nil || s.client.Clientset == nil { + return fmt.Errorf("k8s client not initialized") + } + namespace := s.client.GetNamespace(userID) + if err := s.client.Clientset.CoreV1().Secrets(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to delete secret %s/%s: %w", namespace, name, err) + } + return nil +} diff --git a/backend/internal/services/team_redis.go b/backend/internal/services/team_redis.go new file mode 100644 index 0000000..4e426eb --- /dev/null +++ b/backend/internal/services/team_redis.go @@ -0,0 +1,303 @@ +package services + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/url" + "strconv" + "strings" + "time" +) + +type redisStreamMessage struct { + ID string + Fields map[string]string +} + +type redisBus struct { + address string + password string + db int + useTLS bool +} + +func newRedisBus(rawURL string) (*redisBus, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return nil, fmt.Errorf("redis url is invalid: %w", err) + } + if parsed.Scheme != "redis" && parsed.Scheme != "rediss" { + return nil, fmt.Errorf("redis url scheme must be redis or rediss") + } + address := parsed.Host + if !strings.Contains(address, ":") { + address += ":6379" + } + password, _ := parsed.User.Password() + dbIndex := 0 + if path := strings.Trim(parsed.Path, "/"); path != "" { + parsedDB, err := strconv.Atoi(path) + if err != nil { + return nil, fmt.Errorf("redis db index is invalid: %w", err) + } + dbIndex = parsedDB + } + return &redisBus{ + address: address, + password: password, + db: dbIndex, + useTLS: parsed.Scheme == "rediss", + }, nil +} + +func (b *redisBus) XAdd(ctx context.Context, key string, fields map[string]string) (string, error) { + args := []string{"XADD", key, "*"} + for field, value := range fields { + args = append(args, field, value) + } + reply, err := b.do(ctx, args...) + if err != nil { + return "", err + } + id, ok := reply.(string) + if !ok || strings.TrimSpace(id) == "" { + return "", fmt.Errorf("unexpected redis XADD response") + } + return id, nil +} + +func (b *redisBus) XRead(ctx context.Context, key, lastID string, block time.Duration) ([]redisStreamMessage, error) { + blockMillis := int(block / time.Millisecond) + if blockMillis <= 0 { + blockMillis = 5000 + } + reply, err := b.do(ctx, "XREAD", "BLOCK", strconv.Itoa(blockMillis), "STREAMS", key, lastID) + if err != nil { + return nil, err + } + if reply == nil { + return nil, nil + } + root, ok := reply.([]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected redis XREAD response") + } + var messages []redisStreamMessage + for _, streamRaw := range root { + stream, ok := streamRaw.([]interface{}) + if !ok || len(stream) != 2 { + continue + } + messageList, ok := stream[1].([]interface{}) + if !ok { + continue + } + for _, messageRaw := range messageList { + message, ok := messageRaw.([]interface{}) + if !ok || len(message) != 2 { + continue + } + id, ok := message[0].(string) + if !ok { + continue + } + fieldList, ok := message[1].([]interface{}) + if !ok { + continue + } + fields := map[string]string{} + for i := 0; i+1 < len(fieldList); i += 2 { + field, okField := fieldList[i].(string) + value, okValue := fieldList[i+1].(string) + if okField && okValue { + fields[field] = value + } + } + messages = append(messages, redisStreamMessage{ID: id, Fields: fields}) + } + } + return messages, nil +} + +func (b *redisBus) XRevRange(ctx context.Context, key string, count int) ([]redisStreamMessage, error) { + if count <= 0 { + count = 100 + } + reply, err := b.do(ctx, "XREVRANGE", key, "+", "-", "COUNT", strconv.Itoa(count)) + if err != nil { + return nil, err + } + root, ok := reply.([]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected redis XREVRANGE response") + } + return parseRedisStreamEntries(root), nil +} + +func parseRedisStreamEntries(entries []interface{}) []redisStreamMessage { + messages := make([]redisStreamMessage, 0, len(entries)) + for _, messageRaw := range entries { + message, ok := messageRaw.([]interface{}) + if !ok || len(message) != 2 { + continue + } + id, ok := message[0].(string) + if !ok { + continue + } + fieldList, ok := message[1].([]interface{}) + if !ok { + continue + } + fields := map[string]string{} + for i := 0; i+1 < len(fieldList); i += 2 { + field, okField := fieldList[i].(string) + value, okValue := fieldList[i+1].(string) + if okField && okValue { + fields[field] = value + } + } + messages = append(messages, redisStreamMessage{ID: id, Fields: fields}) + } + return messages +} + +func (b *redisBus) do(ctx context.Context, args ...string) (interface{}, error) { + conn, reader, err := b.connect(ctx) + if err != nil { + return nil, err + } + defer conn.Close() + + if err := writeRedisCommand(conn, args...); err != nil { + return nil, err + } + return readRedisReply(reader) +} + +func (b *redisBus) connect(ctx context.Context) (net.Conn, *bufio.Reader, error) { + dialer := &net.Dialer{Timeout: 5 * time.Second} + var conn net.Conn + var err error + if b.useTLS { + conn, err = tls.DialWithDialer(dialer, "tcp", b.address, &tls.Config{MinVersion: tls.VersionTLS12}) + } else { + conn, err = dialer.DialContext(ctx, "tcp", b.address) + } + if err != nil { + return nil, nil, fmt.Errorf("failed to connect redis: %w", err) + } + reader := bufio.NewReader(conn) + if b.password != "" { + if err := writeRedisCommand(conn, "AUTH", b.password); err != nil { + _ = conn.Close() + return nil, nil, err + } + if _, err := readRedisReply(reader); err != nil { + _ = conn.Close() + return nil, nil, fmt.Errorf("redis auth failed: %w", err) + } + } + if b.db > 0 { + if err := writeRedisCommand(conn, "SELECT", strconv.Itoa(b.db)); err != nil { + _ = conn.Close() + return nil, nil, err + } + if _, err := readRedisReply(reader); err != nil { + _ = conn.Close() + return nil, nil, fmt.Errorf("redis select db failed: %w", err) + } + } + return conn, reader, nil +} + +func writeRedisCommand(conn net.Conn, args ...string) error { + var builder strings.Builder + builder.WriteString("*") + builder.WriteString(strconv.Itoa(len(args))) + builder.WriteString("\r\n") + for _, arg := range args { + builder.WriteString("$") + builder.WriteString(strconv.Itoa(len(arg))) + builder.WriteString("\r\n") + builder.WriteString(arg) + builder.WriteString("\r\n") + } + if _, err := conn.Write([]byte(builder.String())); err != nil { + return fmt.Errorf("failed to write redis command: %w", err) + } + return nil +} + +func readRedisReply(reader *bufio.Reader) (interface{}, error) { + prefix, err := reader.ReadByte() + if err != nil { + return nil, err + } + switch prefix { + case '+': + line, err := readRedisLine(reader) + return line, err + case '-': + line, _ := readRedisLine(reader) + return nil, fmt.Errorf("redis error: %s", line) + case ':': + line, err := readRedisLine(reader) + if err != nil { + return nil, err + } + return strconv.ParseInt(line, 10, 64) + case '$': + line, err := readRedisLine(reader) + if err != nil { + return nil, err + } + size, err := strconv.Atoi(line) + if err != nil { + return nil, err + } + if size < 0 { + return nil, nil + } + buf := make([]byte, size+2) + if _, err := io.ReadFull(reader, buf); err != nil { + return nil, err + } + return string(buf[:size]), nil + case '*': + line, err := readRedisLine(reader) + if err != nil { + return nil, err + } + count, err := strconv.Atoi(line) + if err != nil { + return nil, err + } + if count < 0 { + return nil, nil + } + items := make([]interface{}, 0, count) + for i := 0; i < count; i++ { + item, err := readRedisReply(reader) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil + default: + return nil, fmt.Errorf("unexpected redis reply prefix %q", prefix) + } +} + +func readRedisLine(reader *bufio.Reader) (string, error) { + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} diff --git a/backend/internal/services/team_service.go b/backend/internal/services/team_service.go new file mode 100644 index 0000000..906c167 --- /dev/null +++ b/backend/internal/services/team_service.go @@ -0,0 +1,1798 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "clawreef/internal/models" + "clawreef/internal/repository" + "clawreef/internal/services/k8s" +) + +const ( + teamSharedMountPath = "/team" + teamConfigMountPath = "/team/team.json" + teamConfigFileName = "team.json" + teamRedisURLSecretKey = "CLAWMANAGER_TEAM_REDIS_URL" + teamTokenSecretKey = "CLAWMANAGER_TEAM_TOKEN" + + defaultTeamTaskStaleTimeout = 30 * time.Minute + teamTaskStaleSweepInterval = 30 * time.Second + + initialLeaderTaskIntent = "team_bootstrap_introduction" +) + +var ( + teamMemberKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + teamMemberInstanceNameInvalidChars = regexp.MustCompile(`[^a-z0-9-]+`) + teamMemberInstanceNameRepeatedDashs = regexp.MustCompile(`-+`) +) + +type TeamService interface { + Start() + Stop() + CreateTeam(userID int, req CreateTeamRequest) (*TeamDetailsPayload, error) + ListTeams(userID, offset, limit int) (*TeamListPayload, error) + GetTeam(userID, teamID int) (*TeamDetailsPayload, error) + DispatchTask(userID, teamID int, req DispatchTeamTaskRequest) (*TeamTaskPayload, error) + DeleteTeam(userID, teamID int) error + DeleteMember(userID, teamID int, memberID string) error +} + +type CreateTeamRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + CommunicationMode string `json:"communication_mode,omitempty"` + RedisURL string `json:"redis_url,omitempty"` + SharedStorageGB int `json:"shared_storage_gb,omitempty"` + StorageClass string `json:"storage_class,omitempty"` + Members []CreateTeamMemberRequest `json:"members"` +} + +type CreateTeamMemberRequest struct { + MemberID string `json:"member_id,omitempty"` + Name string `json:"name,omitempty"` + Role string `json:"role"` + RuntimeType string `json:"runtime_type,omitempty"` + Description *string `json:"description,omitempty"` + CPUCores float64 `json:"cpu_cores,omitempty"` + MemoryGB int `json:"memory_gb,omitempty"` + DiskGB int `json:"disk_gb,omitempty"` + GPUEnabled bool `json:"gpu_enabled,omitempty"` + GPUCount int `json:"gpu_count,omitempty"` + ImageRegistry *string `json:"image_registry,omitempty"` + ImageTag *string `json:"image_tag,omitempty"` + EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"` + OpenClawConfigPlan *OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"` + IsLeader bool `json:"is_leader,omitempty"` +} + +type DispatchTeamTaskRequest struct { + TargetMemberID string `json:"target_member_id"` + MessageID string `json:"message_id,omitempty"` + Payload map[string]interface{} `json:"payload"` +} + +type TeamListPayload struct { + Teams []models.Team `json:"teams"` + Total int `json:"total"` +} + +type TeamDetailsPayload struct { + Team *models.Team `json:"team"` + LeaderMemberID string `json:"leader_member_id,omitempty"` + Leader *models.TeamMember `json:"leader,omitempty"` + Members []models.TeamMember `json:"members"` + Tasks []TeamTaskPayload `json:"tasks,omitempty"` + Events []TeamEventPayload `json:"events,omitempty"` +} + +type TeamTaskPayload struct { + models.TeamTask + Payload map[string]interface{} `json:"payload,omitempty"` + Result map[string]interface{} `json:"result,omitempty"` +} + +type TeamEventPayload struct { + models.TeamEvent + Payload map[string]interface{} `json:"payload,omitempty"` +} + +type teamService struct { + repo repository.TeamRepository + instanceService InstanceService + pvcService *k8s.PVCService + secretService *k8s.SecretService + configMapService *k8s.ConfigMapService + + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex + consumers map[int]struct{} + staleMonitorStarted bool +} + +type plannedTeamMember struct { + Request CreateTeamMemberRequest + MemberKey string + DisplayName string + Role string + RuntimeType string + IsLeader bool +} + +func NewTeamService(repo repository.TeamRepository, instanceService InstanceService) TeamService { + ctx, cancel := context.WithCancel(context.Background()) + return &teamService{ + repo: repo, + instanceService: instanceService, + pvcService: k8s.NewPVCService(), + secretService: k8s.NewSecretService(), + configMapService: k8s.NewConfigMapService(), + ctx: ctx, + cancel: cancel, + consumers: map[int]struct{}{}, + } +} + +func (s *teamService) Start() { + teams, err := s.repo.ListActiveTeams() + if err != nil { + fmt.Printf("Warning: failed to start Team event consumers: %v\n", err) + return + } + for _, team := range teams { + s.ensureConsumer(team.ID) + } + s.ensureStaleTaskMonitor() +} + +func (s *teamService) Stop() { + s.cancel() +} + +func (s *teamService) CreateTeam(userID int, req CreateTeamRequest) (*TeamDetailsPayload, error) { + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + return nil, fmt.Errorf("team name is required") + } + if len(req.Members) == 0 { + return nil, fmt.Errorf("team must include at least one member") + } + memberPlans, err := planTeamMembers(req.Name, req.Members) + if err != nil { + return nil, err + } + existingTeam, err := s.repo.GetTeamByUserIDAndName(userID, req.Name) + if err != nil { + return nil, err + } + if existingTeam != nil { + if existingTeam.Status == models.TeamStatusFailed { + if err := s.DeleteTeam(userID, existingTeam.ID); err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("team name already exists") + } + } + + communicationMode := strings.TrimSpace(req.CommunicationMode) + if communicationMode == "" { + communicationMode = "leader_mediated" + } + redisURL := strings.TrimSpace(req.RedisURL) + if redisURL == "" { + redisURL = defaultTeamRedisURL() + } + if redisURL == "" { + return nil, fmt.Errorf("team redis url is required") + } + if _, err := newRedisBus(redisURL); err != nil { + return nil, err + } + + sharedStorageGB := req.SharedStorageGB + if sharedStorageGB <= 0 { + sharedStorageGB = 10 + } + preflightTeam := &models.Team{ + ID: 0, + Name: req.Name, + StorageClass: optionalString(strings.TrimSpace(req.StorageClass)), + SharedMountPath: teamSharedMountPath, + } + if err := s.instanceService.ValidateCreateRequests(userID, s.buildTeamMemberInstanceRequests(preflightTeam, memberPlans)); err != nil { + return nil, err + } + + now := time.Now().UTC() + storageClass := optionalString(strings.TrimSpace(req.StorageClass)) + team := &models.Team{ + UserID: userID, + Name: req.Name, + Description: req.Description, + Status: models.TeamStatusCreating, + CommunicationMode: communicationMode, + RedisEventsLastID: "0-0", + SharedMountPath: teamSharedMountPath, + StorageClass: storageClass, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.CreateTeam(team); err != nil { + return nil, err + } + if err := s.instanceService.ValidateCreateRequests(userID, s.buildTeamMemberInstanceRequests(team, memberPlans)); err != nil { + return nil, s.rollbackTeamCreation(userID, team, err) + } + + if err := s.provisionTeamK8s(userID, team, redisURL, sharedStorageGB, strings.TrimSpace(req.StorageClass)); err != nil { + return nil, s.rollbackTeamCreation(userID, team, err) + } + if err := s.upsertTeamRosterConfig(userID, team, memberPlans); err != nil { + return nil, s.rollbackTeamCreation(userID, team, err) + } + + for _, memberPlan := range memberPlans { + member, err := s.createTeamMemberInstance(userID, team, memberPlan) + if err != nil { + return nil, s.rollbackTeamCreation(userID, team, err) + } + member.Status = models.TeamMemberStatusIdle + member.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateMember(member); err != nil { + return nil, s.rollbackTeamCreation(userID, team, err) + } + } + + team.Status = models.TeamStatusRunning + team.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTeam(team); err != nil { + return nil, err + } + s.ensureConsumer(team.ID) + s.ensureStaleTaskMonitor() + if err := s.dispatchInitialLeaderTask(userID, team); err != nil { + fmt.Printf("Warning: failed to dispatch initial Team %d leader task: %v\n", team.ID, err) + if recordErr := s.recordInitialLeaderTaskDispatchFailure(team.ID, err); recordErr != nil { + fmt.Printf("Warning: failed to record Team %d initial leader task dispatch failure: %v\n", team.ID, recordErr) + } + } + return s.GetTeam(userID, team.ID) +} + +func (s *teamService) dispatchInitialLeaderTask(userID int, team *models.Team) error { + if team == nil { + return fmt.Errorf("team is required") + } + members, err := s.repo.ListMembersByTeamID(team.ID) + if err != nil { + return err + } + leader := findTeamLeader(activeTeamMembers(members)) + if leader == nil { + return fmt.Errorf("team leader not found") + } + _, err = s.DispatchTask(userID, team.ID, DispatchTeamTaskRequest{ + TargetMemberID: leader.MemberKey, + MessageID: initialLeaderTaskMessageID(team.ID), + Payload: buildInitialLeaderTaskPayload(team.Name), + }) + return err +} + +func initialLeaderTaskMessageID(teamID int) string { + return fmt.Sprintf("team-%d-bootstrap-introduction", teamID) +} + +func (s *teamService) recordInitialLeaderTaskDispatchFailure(teamID int, cause error) error { + now := time.Now().UTC() + payload := map[string]interface{}{ + "v": 1, + "event": "bootstrap_dispatch_failed", + "teamId": strconv.Itoa(teamID), + "intent": initialLeaderTaskIntent, + "messageId": initialLeaderTaskMessageID(teamID), + "source": "clawmanager", + } + if cause != nil { + payload["diagnostic"] = cause.Error() + } + payloadJSON, err := marshalOptionalJSON(payload) + if err != nil { + return err + } + messageID := initialLeaderTaskMessageID(teamID) + return s.repo.CreateEvent(&models.TeamEvent{ + TeamID: teamID, + MessageID: &messageID, + EventType: "bootstrap_dispatch_failed", + PayloadJSON: payloadJSON, + OccurredAt: &now, + CreatedAt: now, + }) +} + +func (s *teamService) provisionTeamK8s(userID int, team *models.Team, redisURL string, sharedStorageGB int, storageClass string) error { + ctx := context.Background() + pvc, err := s.pvcService.CreateTeamSharedPVC(ctx, userID, team.ID, sharedStorageGB, storageClass) + if err != nil { + return err + } + secretName := s.pvcService.GetClient().GetTeamSecretName(team.ID) + teamToken, err := generatePrefixedToken("team") + if err != nil { + return fmt.Errorf("failed to generate Team token: %w", err) + } + if err := s.secretService.UpsertSecret(ctx, userID, secretName, map[string]string{ + teamRedisURLSecretKey: redisURL, + teamTokenSecretKey: teamToken, + }, map[string]string{ + "app": "clawreef", + "managed-by": "clawreef", + "team-id": strconv.Itoa(team.ID), + }); err != nil { + return err + } + + team.RedisURLSecretName = &secretName + team.RedisURLSecretKey = optionalString(teamRedisURLSecretKey) + team.TeamTokenSecretName = &secretName + team.TeamTokenSecretKey = optionalString(teamTokenSecretKey) + team.SharedPVCName = &pvc.Name + team.SharedPVCNamespace = &pvc.Namespace + team.UpdatedAt = time.Now().UTC() + return s.repo.UpdateTeam(team) +} + +func (s *teamService) upsertTeamRosterConfig(userID int, team *models.Team, members []plannedTeamMember) error { + rosterJSON, err := buildTeamRosterConfig(team, members) + if err != nil { + return err + } + return s.configMapService.UpsertConfigMap(context.Background(), userID, s.teamConfigMapName(team.ID), map[string]string{ + teamConfigFileName: rosterJSON, + }, map[string]string{ + "app": "clawreef", + "managed-by": "clawreef", + "team-id": strconv.Itoa(team.ID), + }) +} + +func (s *teamService) teamConfigMapName(teamID int) string { + client := k8s.GetClient() + if client == nil { + return fmt.Sprintf("clawreef-team-%d-config", teamID) + } + return client.GetTeamConfigMapName(teamID) +} + +func (s *teamService) createTeamMemberInstance(userID int, team *models.Team, memberPlan plannedTeamMember) (*models.TeamMember, error) { + now := time.Now().UTC() + member := &models.TeamMember{ + TeamID: team.ID, + UserID: userID, + MemberKey: memberPlan.MemberKey, + DisplayName: memberPlan.DisplayName, + Role: memberPlan.Role, + RuntimeType: memberPlan.RuntimeType, + Description: optionalString(strings.TrimSpace(derefTeamString(memberPlan.Request.Description))), + Status: models.TeamMemberStatusCreating, + Availability: models.TeamMemberAvailabilityUnknown, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.CreateMember(member); err != nil { + return nil, err + } + + createReq := s.buildTeamMemberInstanceRequest(team, memberPlan) + instance, err := s.instanceService.Create(userID, createReq) + if err != nil { + member.Status = models.TeamMemberStatusFailed + member.UpdatedAt = time.Now().UTC() + _ = s.repo.UpdateMember(member) + return nil, err + } + member.InstanceID = &instance.ID + member.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateMember(member); err != nil { + return nil, err + } + return member, nil +} + +func (s *teamService) buildTeamMemberInstanceRequests(team *models.Team, memberPlans []plannedTeamMember) []CreateInstanceRequest { + requests := make([]CreateInstanceRequest, 0, len(memberPlans)) + for _, memberPlan := range memberPlans { + requests = append(requests, s.buildTeamMemberInstanceRequest(team, memberPlan)) + } + return requests +} + +func (s *teamService) buildTeamMemberInstanceRequest(team *models.Team, memberPlan plannedTeamMember) CreateInstanceRequest { + req := memberPlan.Request + return CreateInstanceRequest{ + Name: teamMemberInstanceName(team.Name, team.ID, memberPlan.MemberKey), + Type: memberPlan.RuntimeType, + CPUCores: defaultFloat(req.CPUCores, 2), + MemoryGB: defaultInt(req.MemoryGB, 4), + DiskGB: defaultInt(req.DiskGB, 20), + GPUEnabled: req.GPUEnabled, + GPUCount: req.GPUCount, + OSType: memberPlan.RuntimeType, + OSVersion: "latest", + ImageRegistry: req.ImageRegistry, + ImageTag: req.ImageTag, + EnvironmentOverrides: req.EnvironmentOverrides, + StorageClass: derefTeamString(team.StorageClass), + OpenClawConfigPlan: req.OpenClawConfigPlan, + Team: &TeamInstanceConfig{ + Environment: s.teamMemberEnv(team, memberPlan.MemberKey, memberPlan.Role), + SecretName: derefTeamString(team.TeamTokenSecretName), + SharedPVCName: derefTeamString(team.SharedPVCName), + SharedMountPath: team.SharedMountPath, + ConfigMapName: s.teamConfigMapName(team.ID), + ConfigMountPath: teamConfigMountPath, + }, + } +} + +func (s *teamService) teamMemberEnv(team *models.Team, memberKey, role string) map[string]string { + managerBaseURL, _ := defaultTeamManagerBaseURL() + return map[string]string{ + "CLAWMANAGER_TEAM_ENABLED": "true", + "CLAWMANAGER_TEAM_ID": strconv.Itoa(team.ID), + "CLAWMANAGER_TEAM_MEMBER_ID": memberKey, + "CLAWMANAGER_TEAM_ROLE": role, + "CLAWMANAGER_TEAM_SHARED_DIR": team.SharedMountPath, + "CLAWMANAGER_TEAM_CONFIG_PATH": teamConfigMountPath, + "CLAWMANAGER_TEAM_AUTORUN": "true", + "CLAWMANAGER_TEAM_CONSUMER_GROUP": "team-members", + "CLAWMANAGER_TEAM_INBOX_KEY": teamInboxKey(team.ID, memberKey), + "CLAWMANAGER_TEAM_EVENTS_KEY": teamEventsKey(team.ID), + "CLAWMANAGER_TEAM_PRESENCE_KEY": teamPresenceKey(team.ID), + "CLAWMANAGER_TEAM_DLQ_KEY": teamDLQKey(team.ID), + "CLAWMANAGER_TEAM_MANAGER_URL": managerBaseURL, + } +} + +func (s *teamService) ListTeams(userID, offset, limit int) (*TeamListPayload, error) { + teams, err := s.repo.ListTeamsByUserID(userID, offset, limit) + if err != nil { + return nil, err + } + teams = activeTeams(teams) + total, err := s.repo.CountTeamsByUserID(userID) + if err != nil { + return nil, err + } + return &TeamListPayload{Teams: teams, Total: total}, nil +} + +func (s *teamService) GetTeam(userID, teamID int) (*TeamDetailsPayload, error) { + team, err := s.requireOwnedTeam(userID, teamID) + if err != nil { + return nil, err + } + members, err := s.repo.ListMembersByTeamID(teamID) + if err != nil { + return nil, err + } + members = activeTeamMembers(members) + tasks, err := s.repo.ListTasksByTeamID(teamID, 20) + if err != nil { + return nil, err + } + events, err := s.repo.ListEventsByTeamID(teamID, 50) + if err != nil { + return nil, err + } + leader := findTeamLeader(members) + return &TeamDetailsPayload{ + Team: team, + LeaderMemberID: leaderMemberKey(leader), + Leader: leader, + Members: members, + Tasks: teamTaskPayloads(tasks), + Events: teamEventPayloads(events), + }, nil +} + +func (s *teamService) DispatchTask(userID, teamID int, req DispatchTeamTaskRequest) (*TeamTaskPayload, error) { + team, err := s.requireOwnedTeam(userID, teamID) + if err != nil { + return nil, err + } + memberKey := strings.TrimSpace(req.TargetMemberID) + if memberKey == "" { + members, err := s.repo.ListMembersByTeamID(teamID) + if err != nil { + return nil, err + } + memberKey = leaderMemberKey(findTeamLeader(activeTeamMembers(members))) + } + if memberKey == "" { + return nil, fmt.Errorf("target member id is required") + } + if req.Payload == nil { + return nil, fmt.Errorf("task payload is required") + } + member, err := s.repo.GetMemberByTeamKey(teamID, memberKey) + if err != nil { + return nil, err + } + if member == nil { + return nil, fmt.Errorf("team member not found") + } + + messageID := strings.TrimSpace(req.MessageID) + if messageID == "" { + messageID = fmt.Sprintf("team-%d-task-%d", teamID, time.Now().UTC().UnixNano()) + } + existing, err := s.repo.GetTaskByMessageID(teamID, messageID) + if err != nil { + return nil, err + } + if existing != nil { + if existing.TargetMemberID != member.ID { + return nil, fmt.Errorf("team task message id already exists") + } + if existing.Status != models.TeamTaskStatusPending || existing.RedisStreamID != nil { + return teamTaskPayload(*existing) + } + } else { + payloadJSON, err := marshalJSON(req.Payload) + if err != nil { + return nil, fmt.Errorf("failed to encode task payload: %w", err) + } + now := time.Now().UTC() + existing = &models.TeamTask{ + TeamID: teamID, + TargetMemberID: member.ID, + CreatedBy: &userID, + MessageID: messageID, + Status: models.TeamTaskStatusPending, + PayloadJSON: payloadJSON, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.CreateTask(existing); err != nil { + return nil, err + } + } + task := existing + + bus, err := s.redisBusForTeam(context.Background(), team) + if err != nil { + return nil, err + } + taskPayload := map[string]interface{}{} + if strings.TrimSpace(task.PayloadJSON) != "" { + if err := json.Unmarshal([]byte(task.PayloadJSON), &taskPayload); err != nil { + return nil, fmt.Errorf("failed to decode task payload: %w", err) + } + } + now := time.Now().UTC() + envelope := map[string]interface{}{ + "v": 1, + "messageId": messageID, + "teamId": strconv.Itoa(teamID), + "from": "clawmanager", + "to": member.MemberKey, + "intent": eventString(taskPayload, "intent"), + "taskId": fmt.Sprintf("team-%d-task-%d", teamID, task.ID), + "title": eventString(taskPayload, "title"), + "prompt": eventString(taskPayload, "prompt", "goal", "instruction", "instructions"), + "contextRefs": normalizeContextRefs(taskPayload["contextRefs"]), + "metadata": taskPayload, + "createdAt": now.Format(time.RFC3339Nano), + } + if envelope["intent"] == "" { + envelope["intent"] = "run_task" + } + if envelope["title"] == "" { + envelope["title"] = fmt.Sprintf("Team task %d", task.ID) + } + if envelope["prompt"] == "" { + rawPayload, _ := marshalJSON(taskPayload) + envelope["prompt"] = rawPayload + } + envelopeJSON, err := marshalJSON(envelope) + if err != nil { + return nil, fmt.Errorf("failed to encode task envelope: %w", err) + } + streamID, err := bus.XAdd(context.Background(), teamInboxKey(team.ID, member.MemberKey), map[string]string{ + "payload": envelopeJSON, + "team_id": strconv.Itoa(team.ID), + "task_id": strconv.Itoa(task.ID), + "message_id": messageID, + "member_id": member.MemberKey, + }) + if err != nil { + return nil, err + } + task.Status = models.TeamTaskStatusDispatched + task.RedisStreamID = &streamID + task.DispatchedAt = &now + task.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTask(task); err != nil { + return nil, err + } + return teamTaskPayload(*task) +} + +func (s *teamService) DeleteTeam(userID, teamID int) error { + team, err := s.requireOwnedTeam(userID, teamID) + if err != nil { + return err + } + if team.Status == models.TeamStatusDeleted { + return nil + } + + now := time.Now().UTC() + team.Status = models.TeamStatusDeleting + team.UpdatedAt = now + if err := s.repo.UpdateTeam(team); err != nil { + return err + } + + members, err := s.repo.ListMembersByTeamID(teamID) + if err != nil { + return err + } + for idx := range members { + member := members[idx] + if member.Status == models.TeamMemberStatusDeleted { + continue + } + member.Status = models.TeamMemberStatusDeleting + member.UpdatedAt = time.Now().UTC() + _ = s.repo.UpdateMember(&member) + if member.InstanceID != nil && *member.InstanceID > 0 { + if err := s.instanceService.Delete(*member.InstanceID); err != nil { + fmt.Printf("Warning: failed to delete Team %d member %s instance %d: %v\n", teamID, member.MemberKey, *member.InstanceID, err) + } + } + member.Status = models.TeamMemberStatusDeleted + member.CurrentTaskID = nil + member.UpdatedAt = time.Now().UTC() + _ = s.repo.UpdateMember(&member) + } + + ctx := context.Background() + if strings.TrimSpace(derefTeamString(team.TeamTokenSecretName)) != "" { + if err := s.secretService.DeleteSecret(ctx, userID, derefTeamString(team.TeamTokenSecretName)); err != nil { + fmt.Printf("Warning: failed to delete Team %d secret: %v\n", teamID, err) + } + } + if err := s.configMapService.DeleteConfigMap(ctx, userID, s.teamConfigMapName(teamID)); err != nil { + fmt.Printf("Warning: failed to delete Team %d configmap: %v\n", teamID, err) + } + if err := s.pvcService.DeleteTeamSharedPVC(ctx, userID, teamID); err != nil { + fmt.Printf("Warning: failed to delete Team %d shared PVC: %v\n", teamID, err) + } + + team.Name = deletedTeamName(team.Name, team.ID) + team.Status = models.TeamStatusDeleted + team.UpdatedAt = time.Now().UTC() + return s.repo.UpdateTeam(team) +} + +func (s *teamService) DeleteMember(userID, teamID int, memberID string) error { + team, err := s.requireOwnedTeam(userID, teamID) + if err != nil { + return err + } + member, err := s.findTeamMemberForDelete(teamID, memberID) + if err != nil { + return err + } + if member == nil { + return fmt.Errorf("team member not found") + } + if member.UserID != userID || member.TeamID != teamID { + return fmt.Errorf("access denied") + } + if member.Status == models.TeamMemberStatusDeleted { + return nil + } + if isTeamLeaderRole(member.Role) { + return fmt.Errorf("team leader cannot be deleted before assigning a new leader") + } + + now := time.Now().UTC() + member.Status = models.TeamMemberStatusDeleting + member.UpdatedAt = now + if err := s.repo.UpdateMember(member); err != nil { + return err + } + if member.InstanceID != nil && *member.InstanceID > 0 { + if err := s.instanceService.Delete(*member.InstanceID); err != nil { + return err + } + } + member.Status = models.TeamMemberStatusDeleted + member.CurrentTaskID = nil + member.Progress = 0 + member.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateMember(member); err != nil { + return err + } + return s.refreshTeamRosterConfig(userID, team) +} + +func (s *teamService) findTeamMemberForDelete(teamID int, memberID string) (*models.TeamMember, error) { + value := strings.TrimSpace(memberID) + if value == "" { + return nil, fmt.Errorf("team member id is required") + } + if numericID, err := strconv.Atoi(value); err == nil && numericID > 0 { + member, err := s.repo.GetMemberByID(numericID) + if err != nil || member == nil || member.TeamID != teamID { + return member, err + } + return member, nil + } + return s.repo.GetMemberByTeamKey(teamID, value) +} + +func (s *teamService) refreshTeamRosterConfig(userID int, team *models.Team) error { + members, err := s.repo.ListMembersByTeamID(team.ID) + if err != nil { + return err + } + rosterJSON, err := buildTeamRosterConfigFromMembers(team, activeTeamMembers(members)) + if err != nil { + return err + } + return s.configMapService.UpsertConfigMap(context.Background(), userID, s.teamConfigMapName(team.ID), map[string]string{ + teamConfigFileName: rosterJSON, + }, map[string]string{ + "app": "clawreef", + "managed-by": "clawreef", + "team-id": strconv.Itoa(team.ID), + }) +} + +func (s *teamService) requireOwnedTeam(userID, teamID int) (*models.Team, error) { + team, err := s.repo.GetTeamByID(teamID) + if err != nil { + return nil, err + } + if team == nil { + return nil, fmt.Errorf("team not found") + } + if team.UserID != userID { + return nil, fmt.Errorf("access denied") + } + return team, nil +} + +func (s *teamService) ensureConsumer(teamID int) { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.consumers[teamID]; exists { + return + } + s.consumers[teamID] = struct{}{} + go s.consumeTeamEvents(teamID) +} + +func (s *teamService) consumeTeamEvents(teamID int) { + defer func() { + s.mu.Lock() + delete(s.consumers, teamID) + s.mu.Unlock() + }() + + for { + select { + case <-s.ctx.Done(): + return + default: + } + + team, err := s.repo.GetTeamByID(teamID) + if err != nil || team == nil { + time.Sleep(5 * time.Second) + continue + } + bus, err := s.redisBusForTeam(s.ctx, team) + if err != nil { + time.Sleep(5 * time.Second) + continue + } + lastID := strings.TrimSpace(team.RedisEventsLastID) + if lastID == "" { + lastID = "0-0" + } + messages, err := bus.XRead(s.ctx, teamEventsKey(teamID), lastID, 5*time.Second) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + for _, message := range messages { + if err := s.projectTeamEvent(team, bus, message); err != nil { + fmt.Printf("Warning: failed to project Team %d event %s: %v\n", teamID, message.ID, err) + } + team.RedisEventsLastID = message.ID + team.UpdatedAt = time.Now().UTC() + _ = s.repo.UpdateTeam(team) + } + } +} + +func (s *teamService) ensureStaleTaskMonitor() { + s.mu.Lock() + defer s.mu.Unlock() + if s.staleMonitorStarted { + return + } + s.staleMonitorStarted = true + go s.monitorStaleTasks() +} + +func (s *teamService) monitorStaleTasks() { + ticker := time.NewTicker(teamTaskStaleSweepInterval) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + if err := s.sweepStaleTasks(); err != nil { + fmt.Printf("Warning: failed to sweep stale Team tasks: %v\n", err) + } + } + } +} + +func (s *teamService) sweepStaleTasks() error { + timeout := teamTaskStaleTimeout() + if timeout <= 0 { + return nil + } + cutoff := time.Now().UTC().Add(-timeout) + tasks, err := s.repo.ListStaleCandidateTasks(cutoff, 100) + if err != nil { + return err + } + for idx := range tasks { + if err := s.markTaskStale(&tasks[idx], timeout); err != nil { + fmt.Printf("Warning: failed to mark Team task %d stale: %v\n", tasks[idx].ID, err) + } + } + return nil +} + +func (s *teamService) markTaskStale(task *models.TeamTask, timeout time.Duration) error { + if task == nil { + return nil + } + if task.Status != models.TeamTaskStatusDispatched && task.Status != models.TeamTaskStatusRunning { + return nil + } + lastUpdatedAt := task.UpdatedAt + team, err := s.repo.GetTeamByID(task.TeamID) + if err != nil { + return err + } + if team == nil || team.Status == models.TeamStatusDeleted || team.Status == models.TeamStatusDeleting { + return nil + } + + now := time.Now().UTC() + previousStatus := task.Status + task.Status = models.TeamTaskStatusStale + task.FinishedAt = &now + message := fmt.Sprintf("Team task stale: no runtime event for %s since %s", timeout.String(), task.UpdatedAt.Format(time.RFC3339)) + task.ErrorMessage = &message + task.UpdatedAt = now + if err := s.repo.UpdateTask(task); err != nil { + return err + } + + member, err := s.repo.GetMemberByID(task.TargetMemberID) + if err != nil { + return err + } + if member != nil && member.TeamID == task.TeamID && member.CurrentTaskID != nil && *member.CurrentTaskID == task.ID { + member.Status = models.TeamMemberStatusIdle + member.CurrentTaskID = nil + member.Availability = models.TeamMemberAvailabilityBlocked + member.RuntimeTaskID = &task.MessageID + member.RuntimeIntent = nil + member.BlockedReason = &message + member.LastSummary = &message + member.Progress = 0 + member.UpdatedAt = now + if err := s.repo.UpdateMember(member); err != nil { + return err + } + } + + payload := map[string]interface{}{ + "v": 1, + "event": "task_stale", + "teamId": strconv.Itoa(task.TeamID), + "taskId": fmt.Sprintf("team-%d-task-%d", task.TeamID, task.ID), + "messageId": task.MessageID, + "previousStatus": previousStatus, + "staleAfterSeconds": int(timeout.Seconds()), + "lastTaskUpdatedAt": lastUpdatedAt.Format(time.RFC3339Nano), + "diagnostic": message, + "source": "clawmanager", + } + payloadJSON, err := marshalOptionalJSON(payload) + if err != nil { + return err + } + event := &models.TeamEvent{ + TeamID: task.TeamID, + TaskID: &task.ID, + EventType: "task_stale", + MessageID: &task.MessageID, + PayloadJSON: payloadJSON, + OccurredAt: &now, + CreatedAt: now, + } + if member != nil && member.TeamID == task.TeamID { + event.MemberID = &member.ID + } + return s.repo.CreateEvent(event) +} + +func (s *teamService) redisBusForTeam(ctx context.Context, team *models.Team) (*redisBus, error) { + redisURL := "" + if team.RedisURLSecretName != nil && team.RedisURLSecretKey != nil { + client := k8s.GetClient() + if client == nil { + return nil, fmt.Errorf("k8s client not initialized") + } + value, err := s.secretService.GetSecretValue(ctx, client.GetNamespace(team.UserID), *team.RedisURLSecretName, *team.RedisURLSecretKey) + if err != nil { + return nil, err + } + redisURL = strings.TrimSpace(value) + } + if redisURL == "" { + redisURL = defaultTeamRedisURL() + } + if redisURL == "" { + return nil, fmt.Errorf("team redis url is required") + } + return newRedisBus(redisURL) +} + +func (s *teamService) projectTeamEvent(team *models.Team, bus *redisBus, message redisStreamMessage) error { + if exists, err := s.repo.EventExistsByStreamID(team.ID, message.ID); err != nil || exists { + return err + } + payload := mergeRedisEventPayload(message.Fields) + eventType := eventString(payload, "event_type", "event", "type") + if eventType == "" { + eventType = "message" + } + messageID := eventString(payload, "message_id", "messageId") + memberKey := eventString(payload, "member_id", "memberId", "member_key") + if isOutboundTeamEvent(eventType) && messageID != "" && !teamEventHasBody(payload) && bus != nil { + enriched, err := s.enrichOutboundEventFromInbox(team.ID, bus, payload, messageID) + if err != nil { + fmt.Printf("Warning: failed to enrich Team %d outbound event %s from inbox: %v\n", team.ID, messageID, err) + } else { + payload = enriched + } + } + + var member *models.TeamMember + if memberKey != "" { + found, err := s.repo.GetMemberByTeamKey(team.ID, memberKey) + if err != nil { + return err + } + member = found + } + + var task *models.TeamTask + if taskID := eventInt(payload, "task_id", "taskId"); taskID > 0 { + found, err := s.repo.GetTaskByID(taskID) + if err != nil { + return err + } + if found != nil && found.TeamID == team.ID { + task = found + } + } + if task == nil && messageID != "" { + found, err := s.repo.GetTaskByMessageID(team.ID, messageID) + if err != nil { + return err + } + task = found + } + + payloadJSON, err := marshalOptionalJSON(payload) + if err != nil { + return err + } + streamID := message.ID + event := &models.TeamEvent{ + TeamID: team.ID, + EventType: eventType, + PayloadJSON: payloadJSON, + RedisStreamID: &streamID, + OccurredAt: eventTime(payload), + } + if member != nil { + event.MemberID = &member.ID + } + if task != nil { + event.TaskID = &task.ID + } + if messageID != "" { + event.MessageID = &messageID + } + if err := s.repo.CreateEvent(event); err != nil { + return err + } + + now := time.Now().UTC() + if task != nil { + switch eventType { + case "task_received": + if task.Status == models.TeamTaskStatusPending { + task.Status = models.TeamTaskStatusDispatched + } + case "task_started": + if task.Status != models.TeamTaskStatusStale { + task.Status = models.TeamTaskStatusRunning + task.StartedAt = &now + } + case "task_completed": + task.Status = models.TeamTaskStatusSucceeded + task.FinishedAt = &now + task.ResultJSON = payloadJSON + case "task_failed", "message_failed": + task.Status = models.TeamTaskStatusFailed + task.FinishedAt = &now + if errText := eventString(payload, "error_message", "error"); errText != "" { + task.ErrorMessage = &errText + } + } + task.UpdatedAt = now + if err := s.repo.UpdateTask(task); err != nil { + return err + } + } + if member != nil { + member.LastSeenAt = &now + applyTeamMemberRuntimeProjection(member, payload, eventType) + if task != nil && (eventType == "task_received" || eventType == "task_started") { + member.Status = models.TeamMemberStatusBusy + if member.Availability == "" || member.Availability == models.TeamMemberAvailabilityUnknown { + member.Availability = models.TeamMemberAvailabilityBusy + } + member.CurrentTaskID = &task.ID + member.Progress = eventInt(payload, "progress") + } + if eventType == "task_completed" || eventType == "task_failed" || eventType == "message_failed" { + member.Status = models.TeamMemberStatusIdle + member.CurrentTaskID = nil + if eventType == "task_completed" { + member.Progress = 100 + if member.Availability != models.TeamMemberAvailabilityBlocked { + member.Availability = models.TeamMemberAvailabilityIdle + member.BlockedReason = nil + } + } else { + member.Progress = 0 + if member.Availability == "" || member.Availability == models.TeamMemberAvailabilityUnknown { + member.Availability = models.TeamMemberAvailabilityBlocked + } + if member.BlockedReason == nil { + if errText := eventString(payload, "error_message", "error", "reason", "diagnostic", "lastSummary", "last_summary"); errText != "" { + member.BlockedReason = &errText + } + } + } + } + member.UpdatedAt = now + if err := s.repo.UpdateMember(member); err != nil { + return err + } + } + return nil +} + +func (s *teamService) enrichOutboundEventFromInbox(teamID int, bus *redisBus, payload map[string]interface{}, messageID string) (map[string]interface{}, error) { + targetMember := eventString(payload, "to", "recipient", "target", "targetMemberId", "target_member_id") + if targetMember == "" { + return payload, nil + } + var lastErr error + for attempt := 0; attempt < 5; attempt++ { + if attempt > 0 { + time.Sleep(100 * time.Millisecond) + } + messages, err := bus.XRevRange(context.Background(), teamInboxKey(teamID, targetMember), 100) + if err != nil { + lastErr = err + continue + } + for _, inboxMessage := range messages { + if !redisStreamMessageMatches(inboxMessage, messageID) { + continue + } + envelope := mergeRedisEventPayload(inboxMessage.Fields) + return mergeMissingEventFields(payload, envelope), nil + } + } + return payload, lastErr +} + +func redisStreamMessageMatches(message redisStreamMessage, messageID string) bool { + if strings.TrimSpace(message.Fields["message_id"]) == messageID { + return true + } + payload := mergeRedisEventPayload(message.Fields) + return eventString(payload, "message_id", "messageId") == messageID +} + +func mergeMissingEventFields(base map[string]interface{}, extra map[string]interface{}) map[string]interface{} { + merged := map[string]interface{}{} + for key, value := range base { + merged[key] = value + } + for key, value := range extra { + if existing, ok := merged[key]; !ok || isEmptyEventValue(existing) { + merged[key] = value + } + } + if metadata, ok := extra["metadata"].(map[string]interface{}); ok { + for key, value := range metadata { + if existing, ok := merged[key]; !ok || isEmptyEventValue(existing) { + merged[key] = value + } + } + } + return merged +} + +func isEmptyEventValue(value interface{}) bool { + if value == nil { + return true + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) == "" + } + return false +} + +func isOutboundTeamEvent(eventType string) bool { + switch eventType { + case "outbound", "task_assigned": + return true + default: + return false + } +} + +func teamEventHasBody(payload map[string]interface{}) bool { + if eventString(payload, "text", "title", "prompt", "instruction", "instructions", "summary", "resultMarkdown") != "" { + return true + } + for _, key := range []string{"sent", "metadata", "data", "envelope", "task"} { + record, ok := payload[key].(map[string]interface{}) + if !ok { + continue + } + if eventString(record, "text", "title", "prompt", "instruction", "instructions", "summary", "resultMarkdown") != "" { + return true + } + } + return false +} + +func (s *teamService) markTeamFailed(team *models.Team, cause error) error { + team.Status = models.TeamStatusFailed + team.UpdatedAt = time.Now().UTC() + _ = s.repo.UpdateTeam(team) + return cause +} + +func (s *teamService) rollbackTeamCreation(userID int, team *models.Team, cause error) error { + members, err := s.repo.ListMembersByTeamID(team.ID) + if err != nil { + fmt.Printf("Warning: failed to list Team %d members during create rollback: %v\n", team.ID, err) + } + for idx := range members { + member := members[idx] + if member.InstanceID != nil && *member.InstanceID > 0 { + if err := s.instanceService.Delete(*member.InstanceID); err != nil { + fmt.Printf("Warning: failed to delete Team %d member %s instance %d during create rollback: %v\n", team.ID, member.MemberKey, *member.InstanceID, err) + } + } + member.Status = models.TeamMemberStatusDeleted + member.CurrentTaskID = nil + member.UpdatedAt = time.Now().UTC() + _ = s.repo.UpdateMember(&member) + } + + ctx := context.Background() + if strings.TrimSpace(derefTeamString(team.TeamTokenSecretName)) != "" { + if err := s.secretService.DeleteSecret(ctx, userID, derefTeamString(team.TeamTokenSecretName)); err != nil { + fmt.Printf("Warning: failed to delete Team %d secret during create rollback: %v\n", team.ID, err) + } + } + if err := s.configMapService.DeleteConfigMap(ctx, userID, s.teamConfigMapName(team.ID)); err != nil { + fmt.Printf("Warning: failed to delete Team %d configmap during create rollback: %v\n", team.ID, err) + } + if err := s.pvcService.DeleteTeamSharedPVC(ctx, userID, team.ID); err != nil { + fmt.Printf("Warning: failed to delete Team %d shared PVC during create rollback: %v\n", team.ID, err) + } + + team.Name = deletedTeamName(team.Name, team.ID) + team.Status = models.TeamStatusDeleted + team.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTeam(team); err != nil { + fmt.Printf("Warning: failed to mark Team %d deleted during create rollback: %v\n", team.ID, err) + } + return cause +} + +func teamTaskPayloads(tasks []models.TeamTask) []TeamTaskPayload { + result := make([]TeamTaskPayload, 0, len(tasks)) + for _, task := range tasks { + if payload, err := teamTaskPayload(task); err == nil { + result = append(result, *payload) + } + } + return result +} + +func teamTaskPayload(task models.TeamTask) (*TeamTaskPayload, error) { + payload := &TeamTaskPayload{TeamTask: task} + if strings.TrimSpace(task.PayloadJSON) != "" { + if err := json.Unmarshal([]byte(task.PayloadJSON), &payload.Payload); err != nil { + return nil, err + } + } + if task.ResultJSON != nil && strings.TrimSpace(*task.ResultJSON) != "" { + if err := json.Unmarshal([]byte(*task.ResultJSON), &payload.Result); err != nil { + return nil, err + } + } + return payload, nil +} + +func buildInitialLeaderTaskPayload(teamName string) map[string]interface{} { + normalizedTeamName := strings.TrimSpace(teamName) + if normalizedTeamName == "" { + normalizedTeamName = "current" + } + prompt := fmt.Sprintf("请介绍`team %s`当前 Redis Team成员构成,包括各角色的职责分工、运行状态与技术能力边界。同时说明团队内部的协作与通信机制(team_send),例如任务流转方式、消息同步方式、上下文共享方式以及可调用的方法、工具与操作能力,以便后续能够更高效地开展团队工作", normalizedTeamName) + return map[string]interface{}{ + "intent": initialLeaderTaskIntent, + "title": "介绍当前 Redis Team 成员与协作机制", + "prompt": prompt, + } +} + +func teamEventPayloads(events []models.TeamEvent) []TeamEventPayload { + result := make([]TeamEventPayload, 0, len(events)) + for _, event := range events { + payload := TeamEventPayload{TeamEvent: event} + if event.PayloadJSON != nil && strings.TrimSpace(*event.PayloadJSON) != "" { + _ = json.Unmarshal([]byte(*event.PayloadJSON), &payload.Payload) + } + result = append(result, payload) + } + return result +} + +func mergeRedisEventPayload(fields map[string]string) map[string]interface{} { + payload := map[string]interface{}{} + if raw := strings.TrimSpace(fields["payload"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &payload) + } + for key, value := range fields { + if _, exists := payload[key]; !exists { + payload[key] = value + } + } + return payload +} + +func eventString(payload map[string]interface{}, keys ...string) string { + for _, key := range keys { + value, ok := payload[key] + if !ok || value == nil { + continue + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.Itoa(int(typed)) + case int: + return strconv.Itoa(typed) + default: + return strings.TrimSpace(fmt.Sprintf("%v", typed)) + } + } + return "" +} + +func applyTeamMemberRuntimeProjection(member *models.TeamMember, payload map[string]interface{}, eventType string) { + if member == nil { + return + } + availability := normalizeTeamAvailability(eventString(payload, "availability", "memberAvailability")) + explicitlyBlocked := availability == models.TeamMemberAvailabilityBlocked + if availability != "" { + member.Availability = availability + } + if member.Availability == "" { + member.Availability = models.TeamMemberAvailabilityUnknown + } + if runtimeStatus := eventString(payload, "runtime_status", "runtimeStatus", "runtime", "liveness"); runtimeStatus != "" { + member.RuntimeStatus = &runtimeStatus + } + if runtimeTaskID := eventString(payload, "runtime_task_id", "runtimeTaskId", "current_task_id", "currentTaskId", "taskId"); runtimeTaskID != "" { + member.RuntimeTaskID = &runtimeTaskID + } + if runtimeIntent := eventString(payload, "runtime_intent", "runtimeIntent", "current_intent", "currentIntent", "intent"); runtimeIntent != "" { + member.RuntimeIntent = &runtimeIntent + } + if summary := eventString(payload, "last_summary", "lastSummary", "summary", "diagnostic"); summary != "" { + member.LastSummary = &summary + } + if reason := eventString(payload, "blocked_reason", "blockedReason", "error_message", "error", "reason"); reason != "" { + member.BlockedReason = &reason + } + switch eventType { + case "presence", "member_presence", "status", "member_status": + return + case "task_completed": + if !explicitlyBlocked { + member.Availability = models.TeamMemberAvailabilityIdle + member.BlockedReason = nil + } + case "task_failed", "message_failed": + if member.Availability == "" || member.Availability == models.TeamMemberAvailabilityUnknown || member.Availability == models.TeamMemberAvailabilityBusy { + member.Availability = models.TeamMemberAvailabilityBlocked + } + } +} + +func normalizeTeamAvailability(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "idle", "available", "ready": + return models.TeamMemberAvailabilityIdle + case "busy", "running", "working": + return models.TeamMemberAvailabilityBusy + case "blocked", "error", "failed": + return models.TeamMemberAvailabilityBlocked + case "offline", "unavailable": + return models.TeamMemberAvailabilityOffline + case "unknown": + return models.TeamMemberAvailabilityUnknown + default: + return "" + } +} + +func eventInt(payload map[string]interface{}, keys ...string) int { + for _, key := range keys { + value, ok := payload[key] + if !ok || value == nil { + continue + } + switch typed := value.(type) { + case float64: + return int(typed) + case int: + return typed + case string: + parsed, _ := strconv.Atoi(strings.TrimSpace(typed)) + return parsed + } + } + return 0 +} + +func eventTime(payload map[string]interface{}) *time.Time { + for _, key := range []string{"occurred_at", "occurredAt", "timestamp"} { + raw := eventString(payload, key) + if raw == "" { + continue + } + if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return &parsed + } + } + now := time.Now().UTC() + return &now +} + +func normalizeContextRefs(value interface{}) []string { + rawItems, ok := value.([]interface{}) + if !ok { + if typed, ok := value.([]string); ok { + return typed + } + return nil + } + refs := make([]string, 0, len(rawItems)) + for _, item := range rawItems { + ref := strings.TrimSpace(fmt.Sprintf("%v", item)) + if ref != "" { + refs = append(refs, ref) + } + } + return refs +} + +func planTeamMembers(teamName string, members []CreateTeamMemberRequest) ([]plannedTeamMember, error) { + plans := make([]plannedTeamMember, 0, len(members)) + memberKeys := map[string]struct{}{} + leaderCount := 0 + for idx, memberReq := range members { + role := normalizeTeamMemberRole(memberReq.Role, memberReq.IsLeader) + memberKey, err := normalizeTeamMemberKey(memberReq.MemberID, role, idx) + if err != nil { + return nil, err + } + if _, exists := memberKeys[memberKey]; exists { + return nil, fmt.Errorf("duplicate team member id: %s", memberKey) + } + memberKeys[memberKey] = struct{}{} + runtimeType, err := normalizeTeamMemberRuntimeType(memberReq.RuntimeType) + if err != nil { + return nil, err + } + + isLeader := memberReq.IsLeader || isTeamLeaderRole(role) + if isLeader { + leaderCount++ + role = "leader" + } + displayName := strings.TrimSpace(memberReq.Name) + if displayName == "" { + displayName = fmt.Sprintf("%s-%s", teamName, memberKey) + } + plans = append(plans, plannedTeamMember{ + Request: memberReq, + MemberKey: memberKey, + DisplayName: displayName, + Role: role, + RuntimeType: runtimeType, + IsLeader: isLeader, + }) + } + if leaderCount != 1 { + return nil, fmt.Errorf("team must include exactly one leader") + } + return plans, nil +} + +func teamMemberInstanceName(teamName string, teamID int, memberKey string) string { + teamPart := normalizeTeamMemberKeyForInstanceName(teamName) + if teamPart == "" { + teamPart = "team" + } + memberPart := normalizeTeamMemberKeyForInstanceName(memberKey) + if memberPart == "" { + memberPart = "member" + } + const maxInstanceNameLength = 50 + idPart := fmt.Sprintf("%d", teamID) + maxMemberLength := maxInstanceNameLength - len(idPart) - len("--t") + if maxMemberLength < 1 { + maxMemberLength = 1 + } + if len(memberPart) > maxMemberLength { + memberPart = strings.Trim(memberPart[:maxMemberLength], "-") + if memberPart == "" { + memberPart = "member" + } + } + suffix := fmt.Sprintf("-%s-%s", idPart, memberPart) + if len(teamPart)+len(suffix) <= maxInstanceNameLength { + return teamPart + suffix + } + maxTeamLength := maxInstanceNameLength - len(suffix) + if maxTeamLength < 1 { + maxTeamLength = 1 + } + return strings.Trim(teamPart[:maxTeamLength], "-") + suffix +} + +func normalizeTeamMemberKeyForInstanceName(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.ReplaceAll(normalized, " ", "-") + normalized = teamMemberInstanceNameInvalidChars.ReplaceAllString(normalized, "") + normalized = teamMemberInstanceNameRepeatedDashs.ReplaceAllString(normalized, "-") + return strings.Trim(normalized, "-") +} + +func normalizeTeamMemberRuntimeType(raw string) (string, error) { + runtimeType := strings.ToLower(strings.TrimSpace(raw)) + if runtimeType == "" { + return "openclaw", nil + } + switch runtimeType { + case "openclaw", "hermes": + return runtimeType, nil + default: + return "", fmt.Errorf("unsupported team member runtime type: %s", raw) + } +} + +func normalizeTeamMemberRole(raw string, isLeader bool) string { + role := strings.TrimSpace(raw) + if isLeader || isTeamLeaderRole(role) { + return "leader" + } + if role == "" { + return "member" + } + return role +} + +func isTeamLeaderRole(role string) bool { + normalized := strings.ToLower(strings.TrimSpace(role)) + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.ReplaceAll(normalized, " ", "-") + return normalized == "leader" || normalized == "team-leader" +} + +func findTeamLeader(members []models.TeamMember) *models.TeamMember { + for idx := range members { + if isTeamLeaderRole(members[idx].Role) { + member := members[idx] + return &member + } + } + return nil +} + +func leaderMemberKey(member *models.TeamMember) string { + if member == nil { + return "" + } + return member.MemberKey +} + +type teamRosterConfig struct { + Version int `json:"version"` + TeamID string `json:"teamId"` + LeaderMemberID string `json:"leaderMemberId"` + CommunicationMode string `json:"communicationMode"` + SharedDir string `json:"sharedDir"` + Members []teamRosterMember `json:"members"` + Redis teamRosterRedis `json:"redis"` +} + +type teamRosterMember struct { + MemberID string `json:"memberId"` + Role string `json:"role"` + RuntimeType string `json:"runtimeType"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + IsLeader bool `json:"isLeader"` +} + +type teamRosterRedis struct { + EventsKey string `json:"eventsKey"` + PresenceKey string `json:"presenceKey"` + DLQKey string `json:"dlqKey"` +} + +func buildTeamRosterConfig(team *models.Team, members []plannedTeamMember) (string, error) { + config := teamRosterConfig{ + Version: 1, + TeamID: strconv.Itoa(team.ID), + CommunicationMode: team.CommunicationMode, + SharedDir: team.SharedMountPath, + Members: make([]teamRosterMember, 0, len(members)), + Redis: teamRosterRedis{ + EventsKey: teamEventsKey(team.ID), + PresenceKey: teamPresenceKey(team.ID), + DLQKey: teamDLQKey(team.ID), + }, + } + for _, member := range members { + if member.IsLeader { + config.LeaderMemberID = member.MemberKey + } + config.Members = append(config.Members, teamRosterMember{ + MemberID: member.MemberKey, + Role: member.Role, + RuntimeType: member.RuntimeType, + DisplayName: member.DisplayName, + Description: derefTeamString(member.Request.Description), + IsLeader: member.IsLeader, + }) + } + if config.LeaderMemberID == "" { + return "", fmt.Errorf("team must include exactly one leader") + } + return marshalJSON(config) +} + +func buildTeamRosterConfigFromMembers(team *models.Team, members []models.TeamMember) (string, error) { + config := teamRosterConfig{ + Version: 1, + TeamID: strconv.Itoa(team.ID), + CommunicationMode: team.CommunicationMode, + SharedDir: team.SharedMountPath, + Members: make([]teamRosterMember, 0, len(members)), + Redis: teamRosterRedis{ + EventsKey: teamEventsKey(team.ID), + PresenceKey: teamPresenceKey(team.ID), + DLQKey: teamDLQKey(team.ID), + }, + } + for _, member := range members { + isLeader := isTeamLeaderRole(member.Role) + runtimeType := strings.TrimSpace(member.RuntimeType) + if runtimeType == "" { + runtimeType = "openclaw" + } + if isLeader { + config.LeaderMemberID = member.MemberKey + } + config.Members = append(config.Members, teamRosterMember{ + MemberID: member.MemberKey, + Role: member.Role, + RuntimeType: runtimeType, + DisplayName: member.DisplayName, + Description: derefTeamString(member.Description), + IsLeader: isLeader, + }) + } + if config.LeaderMemberID == "" { + return "", fmt.Errorf("team must include exactly one leader") + } + return marshalJSON(config) +} + +func activeTeamMembers(members []models.TeamMember) []models.TeamMember { + active := make([]models.TeamMember, 0, len(members)) + for _, member := range members { + if member.Status == models.TeamMemberStatusDeleted || member.Status == models.TeamMemberStatusDeleting { + continue + } + active = append(active, member) + } + return active +} + +func activeTeams(teams []models.Team) []models.Team { + active := make([]models.Team, 0, len(teams)) + for _, team := range teams { + if team.Status == models.TeamStatusDeleted { + continue + } + active = append(active, team) + } + return active +} + +func deletedTeamName(name string, teamID int) string { + const maxTeamNameLength = 255 + suffix := fmt.Sprintf("__deleted_%d", teamID) + trimmed := strings.TrimSpace(name) + if trimmed == "" { + trimmed = "team" + } + if strings.HasSuffix(trimmed, suffix) { + return trimmed + } + if len(trimmed)+len(suffix) <= maxTeamNameLength { + return trimmed + suffix + } + runes := []rune(trimmed) + maxPrefixLength := maxTeamNameLength - len(suffix) + if len(runes) > maxPrefixLength { + runes = runes[:maxPrefixLength] + } + return string(runes) + suffix +} + +func normalizeTeamMemberKey(raw, role string, index int) (string, error) { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + value = strings.ToLower(strings.TrimSpace(role)) + } + if value == "" { + value = fmt.Sprintf("member-%d", index+1) + } + value = strings.ReplaceAll(value, "_", "-") + value = strings.ReplaceAll(value, " ", "-") + if !teamMemberKeyPattern.MatchString(value) { + return "", fmt.Errorf("team member id is invalid") + } + return value, nil +} + +func defaultTeamRedisURL() string { + for _, key := range []string{"CLAWMANAGER_TEAM_REDIS_URL", "TEAM_REDIS_URL", "REDIS_URL"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return "" +} + +func teamTaskStaleTimeout() time.Duration { + raw := strings.TrimSpace(os.Getenv("CLAWMANAGER_TEAM_TASK_STALE_SECONDS")) + if raw == "" { + return defaultTeamTaskStaleTimeout + } + seconds, err := strconv.Atoi(raw) + if err != nil { + return defaultTeamTaskStaleTimeout + } + if seconds <= 0 { + return 0 + } + return time.Duration(seconds) * time.Second +} + +func defaultTeamManagerBaseURL() (string, bool) { + if override := strings.TrimSpace(os.Getenv("CLAWMANAGER_TEAM_MANAGER_BASE_URL")); override != "" { + return override, true + } + return defaultAgentControlBaseURL() +} + +func teamInboxKey(teamID int, memberID string) string { + return fmt.Sprintf("claw:team:%d:inbox:%s", teamID, memberID) +} + +func teamEventsKey(teamID int) string { + return fmt.Sprintf("claw:team:%d:events", teamID) +} + +func teamPresenceKey(teamID int) string { + return fmt.Sprintf("claw:team:%d:presence", teamID) +} + +func teamDLQKey(teamID int) string { + return fmt.Sprintf("claw:team:%d:dlq", teamID) +} + +func defaultInt(value, fallback int) int { + if value > 0 { + return value + } + return fallback +} + +func defaultFloat(value, fallback float64) float64 { + if value > 0 { + return value + } + return fallback +} + +func derefTeamString(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} diff --git a/backend/internal/services/team_service_test.go b/backend/internal/services/team_service_test.go new file mode 100644 index 0000000..bb6ee5f --- /dev/null +++ b/backend/internal/services/team_service_test.go @@ -0,0 +1,288 @@ +package services + +import ( + "strings" + "testing" + "time" + + "clawreef/internal/models" +) + +func TestTeamMemberEnvUsesSecretBackedRedisAndToken(t *testing.T) { + t.Setenv("CLAWMANAGER_TEAM_MANAGER_BASE_URL", "http://manager.example") + + service := &teamService{} + env := service.teamMemberEnv(&models.Team{ + ID: 12, + SharedMountPath: "/team", + }, "leader", "lead") + + if env["CLAWMANAGER_TEAM_ID"] != "12" { + t.Fatalf("expected Team id env, got %q", env["CLAWMANAGER_TEAM_ID"]) + } + if env["CLAWMANAGER_TEAM_MEMBER_ID"] != "leader" { + t.Fatalf("expected member id env, got %q", env["CLAWMANAGER_TEAM_MEMBER_ID"]) + } + if env["CLAWMANAGER_TEAM_ROLE"] != "lead" { + t.Fatalf("expected Team role env, got %q", env["CLAWMANAGER_TEAM_ROLE"]) + } + if env["CLAWMANAGER_TEAM_INBOX_KEY"] != "claw:team:12:inbox:leader" { + t.Fatalf("unexpected inbox key: %q", env["CLAWMANAGER_TEAM_INBOX_KEY"]) + } + if env["CLAWMANAGER_TEAM_EVENTS_KEY"] != "claw:team:12:events" { + t.Fatalf("unexpected events key: %q", env["CLAWMANAGER_TEAM_EVENTS_KEY"]) + } + if env["CLAWMANAGER_TEAM_MANAGER_URL"] != "http://manager.example" { + t.Fatalf("unexpected manager url: %q", env["CLAWMANAGER_TEAM_MANAGER_URL"]) + } + if env["CLAWMANAGER_TEAM_CONFIG_PATH"] != "/team/team.json" { + t.Fatalf("unexpected Team config path: %q", env["CLAWMANAGER_TEAM_CONFIG_PATH"]) + } + if env["CLAWMANAGER_TEAM_AUTORUN"] != "true" || env["CLAWMANAGER_TEAM_CONSUMER_GROUP"] != "team-members" { + t.Fatalf("expected Team autorun and consumer group env, got %#v", env) + } + for key := range env { + if strings.Contains(key, "REDIS_URL") || strings.Contains(key, "TOKEN") { + t.Fatalf("sensitive Team env %s must come from Secret, not plain env", key) + } + } +} + +func TestNewRedisBusParsesURLWithoutNetwork(t *testing.T) { + bus, err := newRedisBus("redis://:pass@redis.example:6380/3") + if err != nil { + t.Fatalf("newRedisBus returned error: %v", err) + } + if bus.address != "redis.example:6380" || bus.password != "pass" || bus.db != 3 || bus.useTLS { + t.Fatalf("unexpected redis bus config: %#v", bus) + } +} + +func TestPlanTeamMembersRequiresExactlyOneLeader(t *testing.T) { + _, err := planTeamMembers("team", []CreateTeamMemberRequest{ + {MemberID: "worker", Role: "developer"}, + }) + if err == nil || !strings.Contains(err.Error(), "exactly one leader") { + t.Fatalf("expected exactly one leader validation error, got %v", err) + } + + plans, err := planTeamMembers("team", []CreateTeamMemberRequest{ + {MemberID: "lead", Role: "team leader"}, + {MemberID: "worker", Role: "developer"}, + }) + if err != nil { + t.Fatalf("planTeamMembers returned error: %v", err) + } + if len(plans) != 2 || !plans[0].IsLeader || plans[0].Role != "leader" { + t.Fatalf("expected first member to be normalized as leader, got %#v", plans) + } + if plans[1].RuntimeType != "openclaw" { + t.Fatalf("expected default runtime type openclaw, got %#v", plans[1]) + } +} + +func TestPlanTeamMembersSupportsHermesRuntime(t *testing.T) { + plans, err := planTeamMembers("team", []CreateTeamMemberRequest{ + {MemberID: "lead", Role: "leader"}, + {MemberID: "hermes-writer", Role: "writer", RuntimeType: "Hermes"}, + }) + if err != nil { + t.Fatalf("planTeamMembers returned error: %v", err) + } + if plans[1].RuntimeType != "hermes" { + t.Fatalf("expected Hermes runtime to be normalized, got %#v", plans[1]) + } + + _, err = planTeamMembers("team", []CreateTeamMemberRequest{ + {MemberID: "lead", Role: "leader"}, + {MemberID: "worker", Role: "developer", RuntimeType: "ubuntu"}, + }) + if err == nil || !strings.Contains(err.Error(), "unsupported team member runtime type") { + t.Fatalf("expected unsupported runtime validation error, got %v", err) + } +} + +func TestTeamMemberInstanceNameUsesTeamIDAndMemberKey(t *testing.T) { + name := teamMemberInstanceName("Software Engineering Team", 42, "code-reviewer") + if name != "software-engineering-team-42-code-reviewer" { + t.Fatalf("unexpected Team member instance name: %q", name) + } + + longName := teamMemberInstanceName("very-long-software-engineering-platform-team", 12345, "extremely-long-code-reviewer-member-key") + if len(longName) > 50 { + t.Fatalf("expected instance name to stay within 50 chars, got %d: %q", len(longName), longName) + } + if !strings.Contains(longName, "-12345-") { + t.Fatalf("expected instance name to include Team ID, got %q", longName) + } +} + +func TestBuildTeamRosterConfigOmitsSecrets(t *testing.T) { + description := "reviews implementation and validates results" + plans, err := planTeamMembers("team", []CreateTeamMemberRequest{ + {MemberID: "leader", Role: "leader"}, + {MemberID: "worker", Role: "developer", Description: &description}, + }) + if err != nil { + t.Fatalf("planTeamMembers returned error: %v", err) + } + roster, err := buildTeamRosterConfig(&models.Team{ + ID: 9, + CommunicationMode: "leader_mediated", + SharedMountPath: "/team", + }, plans) + if err != nil { + t.Fatalf("buildTeamRosterConfig returned error: %v", err) + } + for _, forbidden := range []string{"REDIS_URL", "TOKEN", "OPENAI_API_KEY", "secret"} { + if strings.Contains(roster, forbidden) { + t.Fatalf("roster must not contain sensitive value marker %q: %s", forbidden, roster) + } + } + if !strings.Contains(roster, `"leaderMemberId":"leader"`) || !strings.Contains(roster, `"eventsKey":"claw:team:9:events"`) { + t.Fatalf("roster missing expected leader or redis keys: %s", roster) + } + if !strings.Contains(roster, description) { + t.Fatalf("roster missing member description: %s", roster) + } + if !strings.Contains(roster, `"runtimeType":"openclaw"`) { + t.Fatalf("roster missing member runtime type: %s", roster) + } +} + +func TestBuildInitialLeaderTaskPayloadDescribesRosterAndTeamSend(t *testing.T) { + payload := buildInitialLeaderTaskPayload("Software Engineering Team") + + if payload["intent"] != initialLeaderTaskIntent { + t.Fatalf("unexpected bootstrap intent: %#v", payload) + } + if payload["title"] == "" { + t.Fatalf("expected bootstrap task title: %#v", payload) + } + prompt, ok := payload["prompt"].(string) + if !ok { + t.Fatalf("expected prompt string: %#v", payload) + } + for _, expected := range []string{ + "`team Software Engineering Team`", + "Redis Team成员构成", + "运行状态与技术能力边界", + "协作与通信机制(team_send)", + "任务流转方式", + "消息同步方式", + "上下文共享方式", + "可调用的方法、工具与操作能力", + } { + if !strings.Contains(prompt, expected) { + t.Fatalf("bootstrap prompt missing %q: %s", expected, prompt) + } + } +} + +func TestActiveTeamMembersFiltersDeletedMembers(t *testing.T) { + members := activeTeamMembers([]models.TeamMember{ + {MemberKey: "leader", Status: models.TeamMemberStatusIdle}, + {MemberKey: "old", Status: models.TeamMemberStatusDeleted}, + {MemberKey: "gone", Status: models.TeamMemberStatusDeleting}, + }) + if len(members) != 1 || members[0].MemberKey != "leader" { + t.Fatalf("unexpected active members: %#v", members) + } +} + +func TestDeletedTeamNameReleasesUniqueName(t *testing.T) { + name := deletedTeamName("DeepResearch", 42) + if name != "DeepResearch__deleted_42" { + t.Fatalf("unexpected deleted Team name: %q", name) + } + if again := deletedTeamName(name, 42); again != name { + t.Fatalf("deleted Team name should be idempotent, got %q", again) + } +} + +func TestTeamTaskStaleTimeoutUsesEnvironment(t *testing.T) { + t.Setenv("CLAWMANAGER_TEAM_TASK_STALE_SECONDS", "60") + if got := teamTaskStaleTimeout(); got != time.Minute { + t.Fatalf("expected one minute stale timeout, got %s", got) + } + + t.Setenv("CLAWMANAGER_TEAM_TASK_STALE_SECONDS", "0") + if got := teamTaskStaleTimeout(); got != 0 { + t.Fatalf("expected disabled stale timeout, got %s", got) + } +} + +func TestApplyTeamMemberRuntimeProjectionSetsBlockedAvailability(t *testing.T) { + member := &models.TeamMember{Availability: models.TeamMemberAvailabilityBusy} + payload := map[string]interface{}{ + "availability": "blocked", + "lastSummary": "Task failed: LLM request failed: network connection error.", + "currentTaskId": "task_cb1062da-dff2-46ff-836f-86490583d944", + "currentIntent": "weather_query_beijing", + } + + applyTeamMemberRuntimeProjection(member, payload, "status") + + if member.Availability != models.TeamMemberAvailabilityBlocked { + t.Fatalf("expected blocked availability, got %q", member.Availability) + } + if member.LastSummary == nil || !strings.Contains(*member.LastSummary, "LLM request failed") { + t.Fatalf("expected last summary projection, got %#v", member.LastSummary) + } + if member.RuntimeTaskID == nil || *member.RuntimeTaskID != "task_cb1062da-dff2-46ff-836f-86490583d944" { + t.Fatalf("expected runtime task id projection, got %#v", member.RuntimeTaskID) + } +} + +func TestApplyTeamMemberRuntimeProjectionClearsStaleBlockedOnCompletion(t *testing.T) { + reason := "previous task failed" + member := &models.TeamMember{ + Availability: models.TeamMemberAvailabilityBlocked, + BlockedReason: &reason, + } + payload := map[string]interface{}{ + "lastSummary": "Redis Team task processing completed", + "currentTaskId": "task_001", + } + + applyTeamMemberRuntimeProjection(member, payload, "task_completed") + + if member.Availability != models.TeamMemberAvailabilityIdle { + t.Fatalf("expected idle availability after task completion, got %q", member.Availability) + } + if member.BlockedReason != nil { + t.Fatalf("expected stale blocked reason to be cleared, got %#v", *member.BlockedReason) + } +} + +func TestMergeMissingEventFieldsEnrichesOutboundPayload(t *testing.T) { + base := map[string]interface{}{ + "event": "outbound", + "messageId": "msg_123", + "from": "leader", + "to": "worker", + } + extra := map[string]interface{}{ + "messageId": "msg_123", + "title": "Check date", + "text": "Check today's date and send the result back.", + "metadata": map[string]interface{}{ + "prompt": "metadata prompt should also be available", + }, + } + + merged := mergeMissingEventFields(base, extra) + + if merged["messageId"] != "msg_123" || merged["from"] != "leader" || merged["to"] != "worker" { + t.Fatalf("base fields should be preserved, got %#v", merged) + } + if merged["title"] != "Check date" || merged["text"] == "" { + t.Fatalf("expected outbound payload to be enriched with title/text, got %#v", merged) + } + if merged["prompt"] != "metadata prompt should also be available" { + t.Fatalf("expected metadata prompt to be merged, got %#v", merged) + } + if !teamEventHasBody(merged) { + t.Fatalf("expected enriched event to have displayable body: %#v", merged) + } +} diff --git a/backend/internal/utils/response.go b/backend/internal/utils/response.go index e13d960..8ba0b9d 100644 --- a/backend/internal/utils/response.go +++ b/backend/internal/utils/response.go @@ -52,15 +52,19 @@ func HandleError(c *gin.Context, err error) { Error(c, http.StatusBadRequest, errStr) return } + if strings.HasPrefix(errStr, "duplicate team member id:") || strings.HasPrefix(errStr, "redis url is invalid:") || strings.HasPrefix(errStr, "redis db index is invalid:") || strings.HasPrefix(errStr, "instance limit reached:") || strings.HasPrefix(errStr, "CPU cores exceed quota:") || strings.HasPrefix(errStr, "memory exceed quota:") || strings.HasPrefix(errStr, "storage exceed quota:") || strings.HasPrefix(errStr, "GPU count exceed quota:") { + Error(c, http.StatusBadRequest, errStr) + return + } switch errStr { - case "username already exists", "email already exists", "instance name already exists", "openclaw config resource key already exists": + case "username already exists", "email already exists", "instance name already exists", "team name already exists", "openclaw config resource key already exists", "team task message id already exists": Error(c, http.StatusConflict, errStr) case "display name already exists": Error(c, http.StatusConflict, errStr) - case "unsupported instance type", "image is required", "display name is required", "provider type is required", "base URL is required", "provider model name is required", "input price must be non-negative", "output price must be non-negative", "base URL is invalid", "automatic model discovery for azure-openai is not supported yet", "provider discovery is not supported", "model is required", "messages are required", "streaming is not supported yet", "provider type is not supported yet", "trace id is required", "event type is required", "message is required", "risk hit record is incomplete", "rule id is required", "rule display name is required", "rule pattern is required", "rule pattern is invalid", "risk severity is invalid", "risk action is invalid", "sample text is required", "secret ref format is invalid", "secret namespace is required in secret ref", "invalid openclaw resource type", "invalid openclaw config plan mode", "openclaw config resource name is required", "openclaw config resource key is invalid", "openclaw config schemaVersion is required", "openclaw config kind does not match resource type", "openclaw config format is required", "openclaw config content is required", "openclaw config content must be valid JSON", "openclaw config config payload is required", "openclaw config dependency type is invalid", "openclaw config dependency key is required", "openclaw config dependency is invalid", "openclaw config bundle name is required", "openclaw config bundle must include at least one resource", "openclaw config bundle resource id is required", "openclaw config bundle contains duplicate resources", "openclaw config bundle is required", "openclaw config bundle is disabled", "openclaw config bundle is empty", "at least one openclaw config resource must be selected", "openclaw config resource id is invalid", "openclaw config resource is disabled", "openclaw config bundle contains a disabled resource", "openclaw bootstrap payload is too large", "agent bootstrap token is required", "agent id is required", "unsupported agent protocol version", "invalid agent bootstrap token", "invalid instance command type", "invalid instance command finish status": + case "unsupported instance type", "instance name is required", "image is required", "display name is required", "provider type is required", "base URL is required", "provider model name is required", "input price must be non-negative", "output price must be non-negative", "base URL is invalid", "automatic model discovery for azure-openai is not supported yet", "provider discovery is not supported", "model is required", "messages are required", "streaming is not supported yet", "provider type is not supported yet", "trace id is required", "event type is required", "message is required", "risk hit record is incomplete", "rule id is required", "rule display name is required", "rule pattern is required", "rule pattern is invalid", "risk severity is invalid", "risk action is invalid", "sample text is required", "secret ref format is invalid", "secret namespace is required in secret ref", "invalid openclaw resource type", "invalid openclaw config plan mode", "openclaw config resource name is required", "openclaw config resource key is invalid", "openclaw config schemaVersion is required", "openclaw config kind does not match resource type", "openclaw config format is required", "openclaw config content is required", "openclaw config content must be valid JSON", "openclaw config config payload is required", "openclaw config dependency type is invalid", "openclaw config dependency key is required", "openclaw config dependency is invalid", "openclaw config bundle name is required", "openclaw config bundle must include at least one resource", "openclaw config bundle resource id is required", "openclaw config bundle contains duplicate resources", "openclaw config bundle is required", "openclaw config bundle is disabled", "openclaw config bundle is empty", "at least one openclaw config resource must be selected", "openclaw config resource id is invalid", "openclaw config resource is disabled", "openclaw config bundle contains a disabled resource", "openclaw bootstrap payload is too large", "agent bootstrap token is required", "agent id is required", "unsupported agent protocol version", "invalid agent bootstrap token", "invalid instance command type", "invalid instance command finish status", "team name is required", "team must include at least one member", "team must include exactly one leader", "team redis url is required", "redis url scheme must be redis or rediss", "team member id is invalid", "target member id is required", "task payload is required", "team leader cannot be deleted before assigning a new leader": Error(c, http.StatusBadRequest, errStr) - case "model is not active or does not exist", "openclaw config resource not found", "openclaw config bundle not found", "openclaw injection snapshot not found", "instance command not found", "instance config revision not found": + case "model is not active or does not exist", "openclaw config resource not found", "openclaw config bundle not found", "openclaw injection snapshot not found", "instance command not found", "instance config revision not found", "team not found", "team member not found": Error(c, http.StatusNotFound, errStr) case "risk rule not found": Error(c, http.StatusNotFound, errStr) diff --git a/deployments/k8s/clawmanager.yaml b/deployments/k8s/clawmanager.yaml index 437648a..fd3a1f6 100644 --- a/deployments/k8s/clawmanager.yaml +++ b/deployments/k8s/clawmanager.yaml @@ -735,6 +735,57 @@ spec: port: 9001 targetPort: console --- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: clawmanager-team-redis + namespace: clawmanager-system +spec: + replicas: 1 + selector: + matchLabels: + app: clawmanager-team-redis + template: + metadata: + labels: + app: clawmanager-team-redis + spec: + containers: + - name: redis + image: redis:7-alpine + imagePullPolicy: IfNotPresent + args: + - redis-server + - --appendonly + - "yes" + ports: + - name: redis + containerPort: 6379 + readinessProbe: + tcpSocket: + port: redis + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + - name: redis-data + mountPath: /data + volumes: + - name: redis-data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: clawmanager-team-redis + namespace: clawmanager-system +spec: + selector: + app: clawmanager-team-redis + ports: + - name: redis + port: 6379 + targetPort: redis +--- apiVersion: v1 kind: ServiceAccount metadata: @@ -887,6 +938,10 @@ spec: value: "manual" - name: K8S_PV_HOST_PATH_PREFIX value: "/data/clawreef" + - name: CLAWMANAGER_TEAM_REDIS_URL + value: "redis://clawmanager-team-redis.clawmanager-system.svc.cluster.local:6379/0" + - name: CLAWMANAGER_TEAM_MANAGER_BASE_URL + value: "http://clawmanager-gateway.clawmanager-system.svc.cluster.local:9001" - name: SKILL_SCANNER_ENABLED value: "true" - name: SKILL_SCANNER_BASE_URL diff --git a/frontend/src/components/InstanceAccess.tsx b/frontend/src/components/InstanceAccess.tsx index 46bdb30..0819446 100644 --- a/frontend/src/components/InstanceAccess.tsx +++ b/frontend/src/components/InstanceAccess.tsx @@ -7,6 +7,8 @@ interface InstanceAccessProps { instanceId: number; instanceName: string; isRunning: boolean; + frameHeightClassName?: string; + containerClassName?: string; overlay?: { gatewayStatus: string; canControl: boolean; @@ -51,6 +53,8 @@ export function InstanceAccess({ instanceId, instanceName, isRunning, + frameHeightClassName, + containerClassName, overlay, }: InstanceAccessProps) { const { t } = useI18n(); @@ -139,7 +143,8 @@ export function InstanceAccess({ const frameHeightClass = isFullscreen ? "min-h-0 flex-1" - : "h-[54vh] min-h-[420px] max-h-[720px] md:h-[58vh] xl:h-[60vh]"; + : frameHeightClassName || + "h-[54vh] min-h-[420px] max-h-[720px] md:h-[58vh] xl:h-[60vh]"; const showStartScreen = !embedUrl; const hasDesktopSession = shouldConnect || Boolean(embedUrl) || loading || reconnecting; @@ -183,7 +188,7 @@ export function InstanceAccess({ return (
{overlay ? ( {overlay ? ( = ({ children, title }) => { const navItems: UserNavItem[] = [ { path: '/dashboard', label: t('nav.userDashboard'), icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', exact: true }, { path: '/instances', label: t('nav.myInstances'), icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01' }, + { path: '/teams', label: 'Teams', icon: 'M17 20h5v-2a4 4 0 00-4-4h-1M9 20H4v-2a4 4 0 014-4h1m8-4a4 4 0 10-8 0 4 4 0 008 0z' }, { path: '/openclaw-configs', label: t('nav.openClawConfigs'), icon: 'M4 6h16M4 12h16M4 18h9' }, { path: '/settings', label: t('nav.settings'), icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }, ]; diff --git a/frontend/src/hooks/useInstanceDesktopAccess.ts b/frontend/src/hooks/useInstanceDesktopAccess.ts index 8549e75..7e62c36 100644 --- a/frontend/src/hooks/useInstanceDesktopAccess.ts +++ b/frontend/src/hooks/useInstanceDesktopAccess.ts @@ -165,6 +165,32 @@ export function useInstanceDesktopAccess({ setReconnecting(false); }, [clearRefreshTimeout, clearRetryTimeout, instanceId]); + useEffect(() => { + clearRetryTimeout(); + clearRefreshTimeout(); + requestIdRef.current += 1; + + const cachedSession = instanceId + ? desktopSessionStore.get(instanceId) + : null; + const nextEmbedUrl = cachedSession?.embedUrl ?? null; + const nextExpiresAt = cachedSession?.expiresAt + ? new Date(cachedSession.expiresAt) + : null; + + embedUrlRef.current = nextEmbedUrl; + expiresAtRef.current = nextExpiresAt; + hasEstablishedSessionRef.current = + cachedSession?.hasEstablishedSession ?? false; + retryAttemptRef.current = 0; + + setEmbedUrl(nextEmbedUrl); + setExpiresAt(nextExpiresAt); + setError(null); + setLoading(false); + setReconnecting(false); + }, [clearRefreshTimeout, clearRetryTimeout, instanceId]); + const shouldPreserveSession = useCallback(() => { return ( retainSessionOnStop && diff --git a/frontend/src/pages/instances/InstanceListPage.tsx b/frontend/src/pages/instances/InstanceListPage.tsx index 4febac1..8ab6e11 100644 --- a/frontend/src/pages/instances/InstanceListPage.tsx +++ b/frontend/src/pages/instances/InstanceListPage.tsx @@ -53,6 +53,32 @@ const mergeInstances = (current: Instance[], incoming: Instance[]) => { }); }; +const INSTANCE_LIST_PAGE_SIZE = 100; + +const instanceTimeValue = (instance: Instance) => { + const value = Date.parse(instance.created_at || instance.updated_at || ''); + return Number.isFinite(value) ? value : 0; +}; + +const sortInstances = (items: Instance[]) => + [...items].sort( + (left, right) => instanceTimeValue(right) - instanceTimeValue(left) || right.id - left.id, + ); + +const loadAllInstances = async () => { + const firstPage = await instanceService.getInstances(1, INSTANCE_LIST_PAGE_SIZE); + const instances = [...(firstPage.instances || [])]; + const total = firstPage.total || instances.length; + const totalPages = Math.ceil(total / INSTANCE_LIST_PAGE_SIZE); + + for (let page = 2; page <= totalPages; page += 1) { + const nextPage = await instanceService.getInstances(page, INSTANCE_LIST_PAGE_SIZE); + instances.push(...(nextPage.instances || [])); + } + + return sortInstances(instances); +}; + interface InstanceItemProps { instance: Instance; actionLoading: number | null; @@ -253,8 +279,8 @@ const InstanceListPage: React.FC = () => { setLoading(true); } setError(null); - const data = await instanceService.getInstances(); - setInstances((prevInstances) => mergeInstances(prevInstances, data.instances)); + const data = await loadAllInstances(); + setInstances((prevInstances) => mergeInstances(prevInstances, data)); } catch (err: any) { setError(err.response?.data?.error || t('instances.failedToLoad')); } finally { diff --git a/frontend/src/pages/teams/CreateTeamPage.tsx b/frontend/src/pages/teams/CreateTeamPage.tsx new file mode 100644 index 0000000..9b28600 --- /dev/null +++ b/frontend/src/pages/teams/CreateTeamPage.tsx @@ -0,0 +1,1670 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import OpenClawConfigPlanSection, { + type OpenClawInjectionMode, +} from "../../components/OpenClawConfigPlanSection"; +import UserLayout from "../../components/UserLayout"; +import { + systemSettingsService, + type SystemImageSetting, +} from "../../services/systemSettingsService"; +import { teamService } from "../../services/teamService"; +import type { CreateTeamRequest } from "../../types/team"; +import type { + OpenClawConfigCompilePreview, + OpenClawConfigPlan, +} from "../../types/openclawConfig"; + +type EnvironmentRow = { + id: string; + name: string; + value: string; +}; + +type TeamMemberDraft = { + id: string; + memberId: string; + name: string; + role: string; + runtimeType: RuntimeType; + description: string; + resourcePreset: ResourcePresetKey; + isLeader: boolean; + cpuCores: number; + memoryGb: number; + diskGb: number; + gpuEnabled: boolean; + gpuCount: number; + image: string; +}; + +type RuntimeType = "openclaw" | "hermes"; +type ResourcePresetKey = "small" | "medium" | "large" | "custom"; +type TeamMemberTemplateMember = Omit; +type TeamMemberTemplate = { + id: string; + name: string; + teamName?: string; + description?: string; + source: "builtin" | "custom"; + members: TeamMemberTemplateMember[]; +}; + +const RUNTIME_OPTIONS: Array<{ value: RuntimeType; label: string }> = [ + { value: "openclaw", label: "OpenClaw" }, + { value: "hermes", label: "Hermes" }, +]; + +const RESOURCE_PRESETS: Record< + Exclude, + { label: string; cpuCores: number; memoryGb: number; diskGb: number } +> = { + small: { label: "小", cpuCores: 2, memoryGb: 4, diskGb: 20 }, + medium: { label: "中", cpuCores: 4, memoryGb: 8, diskGb: 50 }, + large: { label: "大", cpuCores: 8, memoryGb: 16, diskGb: 100 }, +}; + +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const CUSTOM_MEMBER_TEMPLATES_STORAGE_KEY = "clawmanager.team.memberTemplates.v1"; + +const BUILTIN_MEMBER_TEMPLATES: TeamMemberTemplate[] = [ + { + id: "builtin-leader-worker", + name: "标准双成员", + teamName: "research-team", + description: "标准 Leader-mediated Team:Leader 负责目标拆解、成员协调和结果汇总,Worker 负责执行实现任务并同步进展。", + source: "builtin", + members: [ + { + memberId: "leader", + name: "team-leader", + role: "leader", + runtimeType: "openclaw", + description: "负责拆解目标、协调成员、汇总结果和对外回报。", + resourcePreset: "small", + isLeader: true, + cpuCores: 2, + memoryGb: 4, + diskGb: 20, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "worker", + name: "team-worker", + role: "developer", + runtimeType: "openclaw", + description: "负责执行实现任务、提交进展并同步阻塞项。", + resourcePreset: "small", + isLeader: false, + cpuCores: 2, + memoryGb: 4, + diskGb: 20, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + ], + }, + { + id: "builtin-dev-qa-docs", + name: "研发验收三成员", + teamName: "delivery-team", + description: "研发交付 Team:Leader 负责拆解和协调,Developer 负责实现联调,Reviewer 负责测试验证、回归检查和交付复核。", + source: "builtin", + members: [ + { + memberId: "leader", + name: "delivery-lead", + role: "leader", + runtimeType: "openclaw", + description: "负责需求拆解、优先级判断、任务派发和结果汇总。", + resourcePreset: "medium", + isLeader: true, + cpuCores: 4, + memoryGb: 8, + diskGb: 50, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "developer", + name: "developer", + role: "developer", + runtimeType: "openclaw", + description: "负责代码实现、接口联调、必要的单元测试补充。", + resourcePreset: "medium", + isLeader: false, + cpuCores: 4, + memoryGb: 8, + diskGb: 50, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "reviewer", + name: "reviewer", + role: "reviewer", + runtimeType: "openclaw", + description: "负责测试验证、回归检查、文档和交付清单复核。", + resourcePreset: "small", + isLeader: false, + cpuCores: 2, + memoryGb: 4, + diskGb: 20, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + ], + }, + { + id: "builtin-software-engineering-team", + name: "Software-Engineering-Team", + teamName: "software-engineering-team", + description: + "软件工程 Team:Leader 负责目标、任务拆分、协调、风险控制和最终收口;PM 定义产品方向和优先级;UI/UX 负责体验与设计规范;Frontend、Backend、Architect、QA 和 Code Reviewer 分别负责界面、服务、架构、质量验证和代码审查。", + source: "builtin", + members: [ + { + memberId: "leader", + name: "engineering-lead", + role: "leader", + runtimeType: "openclaw", + description: + "负责目标管理、DoD 定义、任务拆分、依赖协调、风险治理、验收和最终决策。", + resourcePreset: "medium", + isLeader: true, + cpuCores: 4, + memoryGb: 8, + diskGb: 50, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "pm", + name: "product-manager", + role: "product-manager", + runtimeType: "openclaw", + description: + "负责需求收集、产品方向、PRD、Roadmap、用户流程、功能边界、优先级和验收标准。", + resourcePreset: "small", + isLeader: false, + cpuCores: 2, + memoryGb: 4, + diskGb: 20, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "ui-ux", + name: "ui-ux-designer", + role: "ui-ux-designer", + runtimeType: "openclaw", + description: + "负责页面视觉、用户体验、交互流程、Figma 设计稿、组件规范和 Design System。", + resourcePreset: "small", + isLeader: false, + cpuCores: 2, + memoryGb: 4, + diskGb: 20, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "frontend", + name: "frontend-engineer", + role: "frontend-engineer", + runtimeType: "openclaw", + description: + "负责 React/Vue 等前端页面开发、接口对接、状态管理、用户交互和性能优化。", + resourcePreset: "medium", + isLeader: false, + cpuCores: 4, + memoryGb: 8, + diskGb: 50, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "backend", + name: "backend-engineer", + role: "backend-engineer", + runtimeType: "openclaw", + description: + "负责 API、数据库、权限、消息队列、微服务、业务逻辑和系统能力实现。", + resourcePreset: "medium", + isLeader: false, + cpuCores: 4, + memoryGb: 8, + diskGb: 50, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "architect", + name: "architect", + role: "architect", + runtimeType: "openclaw", + description: + "负责技术选型、系统拆分、高可用、扩展性、技术规范和长期演进方案。", + resourcePreset: "medium", + isLeader: false, + cpuCores: 4, + memoryGb: 8, + diskGb: 50, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "qa", + name: "qa-engineer", + role: "qa-engineer", + runtimeType: "openclaw", + description: + "负责功能测试、自动化测试、回归测试、压测、Bug 管理和稳定性验证。", + resourcePreset: "small", + isLeader: false, + cpuCores: 2, + memoryGb: 4, + diskGb: 20, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + { + memberId: "code-reviewer", + name: "code-reviewer", + role: "code-reviewer", + runtimeType: "openclaw", + description: + "负责代码审查、架构一致性、可维护性、测试覆盖、风险点、回归影响和合并前质量把关。", + resourcePreset: "small", + isLeader: false, + cpuCores: 2, + memoryGb: 4, + diskGb: 20, + gpuEnabled: false, + gpuCount: 0, + image: "", + }, + ], + }, +]; + +const newDraftId = () => + `member-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + +const defaultMember = ( + overrides?: Partial, +): TeamMemberDraft => ({ + id: newDraftId(), + memberId: "worker", + name: "", + role: "developer", + runtimeType: "openclaw", + description: "", + resourcePreset: "small", + isLeader: false, + cpuCores: 2, + memoryGb: 4, + diskGb: 20, + gpuEnabled: false, + gpuCount: 0, + image: "", + ...overrides, +}); + +const imageOptionKey = (item: SystemImageSetting) => + item.id != null + ? `image-${item.id}` + : `${item.instance_type}:${item.image}`; + +const normalizeMemberId = (value: string) => + value + .trim() + .toLowerCase() + .replace(/[_\s]+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .slice(0, 63); + +const loadCustomMemberTemplates = (): TeamMemberTemplate[] => { + try { + if (typeof window === "undefined") { + return []; + } + const raw = window.localStorage.getItem(CUSTOM_MEMBER_TEMPLATES_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter((item): item is TeamMemberTemplate => { + return ( + item && + typeof item.id === "string" && + typeof item.name === "string" && + Array.isArray(item.members) + ); + }) + .map((item) => ({ + ...item, + source: "custom" as const, + })); + } catch { + return []; + } +}; + +const saveCustomMemberTemplates = (templates: TeamMemberTemplate[]) => { + try { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem( + CUSTOM_MEMBER_TEMPLATES_STORAGE_KEY, + JSON.stringify(templates), + ); + } catch { + // localStorage can be unavailable in private browsing or strict webviews. + } +}; + +const uniqueMemberId = (raw: string, usedIds: Set, fallbackIndex: number) => { + const fallback = `member-${fallbackIndex}`; + const base = normalizeMemberId(raw) || fallback; + let candidate = base; + let suffix = 2; + + while (usedIds.has(candidate)) { + const suffixText = `-${suffix}`; + candidate = `${base.slice(0, 63 - suffixText.length)}${suffixText}`; + suffix += 1; + } + + usedIds.add(candidate); + return candidate; +}; + +const CreateTeamPage: React.FC = () => { + const navigate = useNavigate(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [sharedStorageGb, setSharedStorageGb] = useState(10); + const [storageClass, setStorageClass] = useState(""); + const [images, setImages] = useState([]); + const [selectedImageKey, setSelectedImageKey] = useState(""); + const [customMemberTemplates, setCustomMemberTemplates] = useState< + TeamMemberTemplate[] + >(() => loadCustomMemberTemplates()); + const [selectedTemplateId, setSelectedTemplateId] = useState( + BUILTIN_MEMBER_TEMPLATES[0]?.id || "", + ); + const [templatePackageName, setTemplatePackageName] = useState(""); + const [templateNotice, setTemplateNotice] = useState(null); + const [members, setMembers] = useState(() => [ + defaultMember({ + memberId: "leader", + role: "leader", + isLeader: true, + }), + defaultMember({ + memberId: "worker", + role: "developer", + }), + ]); + const [loadingImages, setLoadingImages] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [environmentRows, setEnvironmentRows] = useState([]); + const [openClawInjectionMode, setOpenClawInjectionMode] = + useState("none"); + const [openClawBundleId, setOpenClawBundleId] = useState(); + const [openClawResourceIds, setOpenClawResourceIds] = useState([]); + const [openClawPreview, setOpenClawPreview] = + useState(null); + const [openClawPreviewLoading, setOpenClawPreviewLoading] = useState(false); + const [openClawPreviewError, setOpenClawPreviewError] = useState( + null, + ); + + const openClawImages = useMemo( + () => + images.filter( + (item) => item.instance_type === "openclaw" && item.is_enabled !== false, + ), + [images], + ); + const hermesImages = useMemo( + () => + images.filter( + (item) => item.instance_type === "hermes" && item.is_enabled !== false, + ), + [images], + ); + const selectedImage = + openClawImages.find((item) => imageOptionKey(item) === selectedImageKey) || + openClawImages[0]; + const memberTemplates = useMemo( + () => [...BUILTIN_MEMBER_TEMPLATES, ...customMemberTemplates], + [customMemberTemplates], + ); + const selectedTemplate = useMemo( + () => + memberTemplates.find((template) => template.id === selectedTemplateId) || + memberTemplates[0], + [memberTemplates, selectedTemplateId], + ); + const imageOptionsForRuntime = useCallback( + (runtimeType: RuntimeType) => + runtimeType === "hermes" ? hermesImages : openClawImages, + [hermesImages, openClawImages], + ); + + useEffect(() => { + const loadImages = async () => { + try { + setLoadingImages(true); + const items = await systemSettingsService.getImageSettings(); + setImages(items); + } catch { + setImages([]); + } finally { + setLoadingImages(false); + } + }; + void loadImages(); + }, []); + + useEffect(() => { + if (openClawImages.length === 0) { + setSelectedImageKey(""); + return; + } + setSelectedImageKey((current) => + openClawImages.some((item) => imageOptionKey(item) === current) + ? current + : imageOptionKey(openClawImages[0]), + ); + }, [openClawImages]); + + useEffect(() => { + saveCustomMemberTemplates(customMemberTemplates); + }, [customMemberTemplates]); + + useEffect(() => { + if ( + memberTemplates.length > 0 && + !memberTemplates.some((template) => template.id === selectedTemplateId) + ) { + setSelectedTemplateId(memberTemplates[0].id); + } + }, [memberTemplates, selectedTemplateId]); + + useEffect(() => { + setMembers((current) => + current.map((member) => { + const options = imageOptionsForRuntime(member.runtimeType); + const fallbackImage = options[0]?.image || ""; + const imageMatchesRuntime = options.some( + (item) => item.image === member.image, + ); + if ((!member.image || !imageMatchesRuntime) && fallbackImage) { + return { ...member, image: fallbackImage }; + } + return member; + }), + ); + }, [imageOptionsForRuntime]); + + const updateMember = ( + id: string, + patch: + | Partial + | ((current: TeamMemberDraft) => Partial), + ) => { + setMembers((current) => + current.map((member) => { + if (member.id !== id) { + return member; + } + const nextPatch = typeof patch === "function" ? patch(member) : patch; + return { ...member, ...nextPatch }; + }), + ); + }; + + const setMemberRuntimeType = (id: string, runtimeType: RuntimeType) => { + const fallbackImage = imageOptionsForRuntime(runtimeType)[0]?.image || ""; + updateMember(id, { runtimeType, image: fallbackImage }); + }; + + const applyResourcePreset = (id: string, preset: ResourcePresetKey) => { + if (preset === "custom") { + updateMember(id, { resourcePreset: "custom" }); + return; + } + const config = RESOURCE_PRESETS[preset]; + updateMember(id, { + resourcePreset: preset, + cpuCores: config.cpuCores, + memoryGb: config.memoryGb, + diskGb: config.diskGb, + }); + }; + + const setLeader = (id: string) => { + setMembers((current) => + current.map((member) => ({ + ...member, + isLeader: member.id === id, + role: member.id === id ? "leader" : member.role === "leader" ? "developer" : member.role, + })), + ); + }; + + const addMember = () => { + const nextIndex = members.length + 1; + setMembers((current) => [ + ...current, + defaultMember({ + memberId: `worker-${nextIndex}`, + image: selectedImage?.image || "", + }), + ]); + }; + + const removeMember = (id: string) => { + setMembers((current) => { + const next = current.filter((member) => member.id !== id); + if (next.length > 0 && !next.some((member) => member.isLeader)) { + return next.map((member, index) => + index === 0 + ? { ...member, isLeader: true, role: "leader" } + : member, + ); + } + return next; + }); + }; + + const draftFromTemplateMember = ( + templateMember: TeamMemberTemplateMember, + usedIds: Set, + index: number, + isLeader: boolean, + ): TeamMemberDraft => { + const runtimeType = templateMember.runtimeType || "openclaw"; + const runtimeImages = imageOptionsForRuntime(runtimeType); + const templateImageAvailable = runtimeImages.some( + (item) => item.image === templateMember.image, + ); + const image = templateImageAvailable + ? templateMember.image + : runtimeImages[0]?.image || templateMember.image || ""; + const role = + isLeader || templateMember.role !== "leader" + ? templateMember.role + : "developer"; + + return defaultMember({ + ...templateMember, + memberId: uniqueMemberId(templateMember.memberId, usedIds, index), + role: isLeader ? "leader" : role || "member", + runtimeType, + isLeader, + image, + }); + }; + + const buildTemplateMembers = ( + template: TeamMemberTemplate, + existingMembers: TeamMemberDraft[], + ) => { + const usedIds = new Set( + existingMembers + .map((member) => normalizeMemberId(member.memberId)) + .filter(Boolean), + ); + const importedMembers: TeamMemberDraft[] = []; + let leaderAssigned = existingMembers.some((member) => member.isLeader); + + template.members.forEach((templateMember, index) => { + const shouldBeLeader = Boolean(templateMember.isLeader) && !leaderAssigned; + if (shouldBeLeader) { + leaderAssigned = true; + } + importedMembers.push( + draftFromTemplateMember(templateMember, usedIds, index + 1, shouldBeLeader), + ); + }); + + if (!leaderAssigned && importedMembers.length > 0) { + importedMembers[0] = { + ...importedMembers[0], + isLeader: true, + role: "leader", + }; + } + + return importedMembers; + }; + + const importMemberTemplate = (mode: "replace" | "append") => { + if (!selectedTemplate) { + return; + } + if (selectedTemplate.teamName) { + setName(selectedTemplate.teamName); + } + if (selectedTemplate.description) { + setDescription(selectedTemplate.description); + } + setMembers((current) => { + const existingMembers = mode === "append" ? current : []; + const importedMembers = buildTemplateMembers(selectedTemplate, existingMembers); + return mode === "append" ? [...current, ...importedMembers] : importedMembers; + }); + setTemplateNotice( + mode === "append" + ? `已追加模板包:${selectedTemplate.name}` + : `已导入模板包:${selectedTemplate.name}`, + ); + setError(null); + }; + + const buildTemplateFromCurrentMembers = ( + packageName: string, + templateId?: string, + ): TeamMemberTemplate | null => { + const templateMembers = members.map((member, index) => ({ + memberId: normalizeMemberId(member.memberId) || `member-${index + 1}`, + name: member.name.trim(), + role: member.isLeader ? "leader" : member.role.trim() || "member", + runtimeType: member.runtimeType, + description: member.description.trim(), + resourcePreset: member.resourcePreset, + isLeader: member.isLeader, + cpuCores: member.cpuCores, + memoryGb: member.memoryGb, + diskGb: member.diskGb, + gpuEnabled: member.gpuEnabled, + gpuCount: member.gpuEnabled ? member.gpuCount : 0, + image: member.image.trim(), + })); + + return { + id: + templateId || + `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + name: packageName, + teamName: name.trim() || undefined, + description: description.trim() || undefined, + source: "custom", + members: templateMembers, + }; + }; + + const saveCurrentMembersAsTemplate = () => { + const packageName = templatePackageName.trim(); + if (!packageName) { + setError("模板包名称不能为空"); + return; + } + if (members.length === 0) { + setError("至少需要一个成员才能保存模板"); + return; + } + + const template = buildTemplateFromCurrentMembers(packageName); + if (!template) { + return; + } + + setCustomMemberTemplates((current) => [...current, template]); + setSelectedTemplateId(template.id); + setTemplatePackageName(""); + setTemplateNotice(`已保存模板包:${packageName}`); + setError(null); + }; + + const updateSelectedTemplate = () => { + if (!selectedTemplate || selectedTemplate.source !== "custom") { + setError("只能编辑自定义模板,请先把内置模板另存为自定义模板"); + return; + } + if (members.length === 0) { + setError("至少需要一个成员才能更新模板"); + return; + } + const packageName = templatePackageName.trim() || selectedTemplate.name; + const updatedTemplate = buildTemplateFromCurrentMembers( + packageName, + selectedTemplate.id, + ); + if (!updatedTemplate) { + return; + } + + setCustomMemberTemplates((current) => + current.map((template) => + template.id === selectedTemplate.id ? updatedTemplate : template, + ), + ); + setTemplatePackageName(""); + setTemplateNotice(`已更新模板包:${packageName}`); + setError(null); + }; + + const deleteSelectedTemplate = () => { + if (!selectedTemplate || selectedTemplate.source !== "custom") { + return; + } + setCustomMemberTemplates((current) => + current.filter((template) => template.id !== selectedTemplate.id), + ); + setSelectedTemplateId(BUILTIN_MEMBER_TEMPLATES[0]?.id || ""); + setTemplateNotice(`已删除模板包:${selectedTemplate.name}`); + }; + + const addEnvironmentRow = () => { + setEnvironmentRows((current) => [ + ...current, + { + id: `env-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + name: "", + value: "", + }, + ]); + }; + + const updateEnvironmentRow = ( + id: string, + patch: Partial>, + ) => { + setEnvironmentRows((current) => + current.map((row) => (row.id === id ? { ...row, ...patch } : row)), + ); + }; + + const removeEnvironmentRow = (id: string) => { + setEnvironmentRows((current) => current.filter((row) => row.id !== id)); + }; + + const buildEnvironmentOverridesPayload = () => { + const overrides: Record = {}; + const seenNames = new Set(); + + for (const row of environmentRows) { + const envName = row.name.trim(); + const hasName = envName.length > 0; + const hasValue = row.value.length > 0; + + if (!hasName && !hasValue) { + continue; + } + if (!hasName) { + return { error: "环境变量名称不能为空" }; + } + if (!ENV_NAME_PATTERN.test(envName)) { + return { error: `环境变量名称无效:${envName}` }; + } + if (seenNames.has(envName)) { + return { error: `环境变量名称重复:${envName}` }; + } + seenNames.add(envName); + overrides[envName] = row.value; + } + + return { + overrides: Object.keys(overrides).length > 0 ? overrides : undefined, + }; + }; + + const buildOpenClawConfigPlan = (): OpenClawConfigPlan | undefined => { + if (openClawInjectionMode === "bundle" && openClawBundleId) { + return { mode: "bundle", bundle_id: openClawBundleId }; + } + if (openClawInjectionMode === "manual" && openClawResourceIds.length > 0) { + return { mode: "manual", resource_ids: openClawResourceIds }; + } + return undefined; + }; + + const handleOpenClawPreviewChange = useCallback( + ( + preview: OpenClawConfigCompilePreview | null, + state: { loading: boolean; error: string | null }, + ) => { + setOpenClawPreview(preview); + setOpenClawPreviewLoading(state.loading); + setOpenClawPreviewError(state.error); + }, + [], + ); + + const validationError = useMemo(() => { + if (!name.trim()) { + return "Team 名称不能为空"; + } + if (members.length === 0) { + return "至少需要一个成员"; + } + if (members.filter((member) => member.isLeader).length !== 1) { + return "必须指定且只能指定一个 Leader"; + } + const memberIds = new Set(); + for (const member of members) { + const memberId = normalizeMemberId(member.memberId); + if (!memberId) { + return "成员 ID 不能为空"; + } + if (memberIds.has(memberId)) { + return `成员 ID 重复:${memberId}`; + } + memberIds.add(memberId); + if (!member.image.trim()) { + return `成员 ${memberId} 未选择镜像`; + } + if (member.cpuCores <= 0 || member.memoryGb <= 0 || member.diskGb <= 0) { + return `成员 ${memberId} 的资源规格无效`; + } + } + return null; + }, [members, name]); + + const environmentDraft = useMemo( + () => buildEnvironmentOverridesPayload(), + [environmentRows], + ); + const openClawPlanInvalid = + (openClawInjectionMode === "bundle" && + (!openClawBundleId || Boolean(openClawPreviewError) || openClawPreviewLoading)) || + (openClawInjectionMode === "manual" && + (Boolean(openClawPreviewError) || openClawPreviewLoading)) || + openClawInjectionMode === "archive"; + const environmentOverrideNames = environmentDraft.overrides + ? Object.keys(environmentDraft.overrides) + : []; + const resolvedChannelNames = (openClawPreview?.resolved_resources || []) + .filter((resource) => resource.resource_type === "channel") + .map((resource) => resource.name); + + const submitDisabled = + submitting || + Boolean(validationError) || + Boolean(environmentDraft.error) || + openClawPlanInvalid; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (validationError) { + setError(validationError); + return; + } + if (environmentDraft.error) { + setError(environmentDraft.error); + return; + } + if (openClawPlanInvalid) { + setError( + openClawInjectionMode === "archive" + ? "Team 创建暂不支持 Archive 导入,请选择手动/Bundle 或关闭注入。" + : openClawPreviewError || "OpenClaw 注入配置尚未就绪", + ); + return; + } + const openClawConfigPlan = buildOpenClawConfigPlan(); + const payload: CreateTeamRequest = { + name: name.trim(), + description: description.trim() || undefined, + communication_mode: "leader_mediated", + shared_storage_gb: sharedStorageGb, + storage_class: storageClass.trim() || undefined, + members: members.map((member) => ({ + member_id: normalizeMemberId(member.memberId), + name: member.name.trim() || undefined, + role: member.isLeader ? "leader" : member.role.trim() || "member", + runtime_type: member.runtimeType, + description: member.description.trim() || undefined, + is_leader: member.isLeader, + cpu_cores: member.cpuCores, + memory_gb: member.memoryGb, + disk_gb: member.diskGb, + gpu_enabled: member.gpuEnabled, + gpu_count: member.gpuEnabled ? member.gpuCount : 0, + image_registry: member.image.trim(), + environment_overrides: environmentDraft.overrides, + openclaw_config_plan: openClawConfigPlan, + })), + }; + + try { + setSubmitting(true); + setError(null); + const created = await teamService.createTeam(payload); + navigate(`/teams/${created.team.id}`); + } catch (err: any) { + setError(err.response?.data?.error || "创建 Team 失败"); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ {error && ( +
+ {error} +
+ )} + +
+
+
+
+ + +