diff --git a/agent/robot/api/api_test.go b/agent/robot/api/api_test.go index d27e170a..9ad65c7a 100644 --- a/agent/robot/api/api_test.go +++ b/agent/robot/api/api_test.go @@ -462,11 +462,17 @@ func cleanupAPITestRobots(t *testing.T) { tableName := m.MetaData.Table.Name qb := capsule.Query() - // Delete all robots with member_id starting with "robot_api_" + // Delete all robots with member_id starting with "robot_api_" or "api_robot_" _, err := qb.Table(tableName).Where("member_id", "like", "robot_api_%").Delete() if err != nil { t.Logf("Warning: cleanup robots error: %v", err) } + + // Also delete "api_robot_" prefixed robots (new tests) + _, err = qb.Table(tableName).Where("member_id", "like", "api_robot_%").Delete() + if err != nil { + t.Logf("Warning: cleanup robots error: %v", err) + } } // cleanupAPITestExecutions removes all API test executions diff --git a/agent/robot/api/robot.go b/agent/robot/api/robot.go index 9d096236..9ad3a360 100644 --- a/agent/robot/api/robot.go +++ b/agent/robot/api/robot.go @@ -1,10 +1,13 @@ package api import ( + "context" "fmt" + "time" "github.com/yaoapp/gou/model" "github.com/yaoapp/kun/maps" + "github.com/yaoapp/yao/agent/robot/store" "github.com/yaoapp/yao/agent/robot/types" ) @@ -14,6 +17,9 @@ import ( // memberModel is the model name for member table const memberModel = "__yao.member" +// robotStore is the shared robot store instance +var robotStore = store.NewRobotStore() + // GetRobot returns a robot by member ID // Returns the robot from cache if available, otherwise loads from database func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) { @@ -81,6 +87,7 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) { MemberID: robot.MemberID, TeamID: robot.TeamID, DisplayName: robot.DisplayName, + Bio: robot.Bio, Status: robot.Status, Running: robot.RunningCount(), MaxRunning: 2, // default @@ -121,7 +128,7 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) { records, err := m.Get(model.QueryParam{ Select: []interface{}{ - "id", "member_id", "team_id", "display_name", + "id", "member_id", "team_id", "display_name", "bio", "system_prompt", "robot_status", "autonomous_mode", "robot_config", "robot_email", }, @@ -181,7 +188,7 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) { // Execute paginated query result, err := m.Paginate(model.QueryParam{ Select: []interface{}{ - "id", "member_id", "team_id", "display_name", + "id", "member_id", "team_id", "display_name", "bio", "system_prompt", "robot_status", "autonomous_mode", "robot_config", "robot_email", }, @@ -256,3 +263,312 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult { PageSize: query.PageSize, } } + +// ==================== Robot CRUD API ==================== +// These functions create, update, and delete robots +// They call store layer for persistence and manage cache +// Request/Response types are defined in types.go + +// CreateRobot creates a new robot member +// Calls store.RobotStore.Save() and refreshes cache +func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, error) { + // Validate required fields + if req.MemberID == "" { + return nil, fmt.Errorf("member_id is required") + } + if req.TeamID == "" { + return nil, fmt.Errorf("team_id is required") + } + if req.DisplayName == "" { + return nil, fmt.Errorf("display_name is required") + } + + // Check if robot already exists + existing, err := robotStore.Get(context.Background(), req.MemberID) + if err != nil { + return nil, fmt.Errorf("failed to check existing robot: %w", err) + } + if existing != nil { + return nil, fmt.Errorf("robot with member_id '%s' already exists", req.MemberID) + } + + // Determine autonomous_mode value + autonomousMode := false + if req.AutonomousMode != nil { + autonomousMode = *req.AutonomousMode + } + + // Determine status values + status := "active" + if req.Status != "" { + status = req.Status + } + robotStatus := "idle" + if req.RobotStatus != "" { + robotStatus = req.RobotStatus + } + + // Create store record with all fields + now := time.Now() + record := &store.RobotRecord{ + // Required + MemberID: req.MemberID, + TeamID: req.TeamID, + MemberType: "robot", + Status: status, + RobotStatus: robotStatus, + AutonomousMode: autonomousMode, + + // Profile + DisplayName: req.DisplayName, + Bio: req.Bio, + Avatar: req.Avatar, + + // Identity & Role + SystemPrompt: req.SystemPrompt, + RoleID: req.RoleID, + ManagerID: req.ManagerID, + + // Communication + RobotEmail: req.RobotEmail, + AuthorizedSenders: req.AuthorizedSenders, + EmailFilterRules: req.EmailFilterRules, + + // Capabilities + RobotConfig: req.RobotConfig, + Agents: req.Agents, + MCPServers: req.MCPServers, + LanguageModel: req.LanguageModel, + + // Limits + CostLimit: req.CostLimit, + + // Timestamps + JoinedAt: &now, + } + + // Apply Yao permission fields if provided + if req.AuthScope != nil { + record.YaoCreatedBy = req.AuthScope.CreatedBy + record.YaoTeamID = req.AuthScope.TeamID + record.YaoTenantID = req.AuthScope.TenantID + // Set invited_by from CreatedBy if not explicitly set + if record.InvitedBy == "" && req.AuthScope.CreatedBy != "" { + record.InvitedBy = req.AuthScope.CreatedBy + } + } + + // Save to database + err = robotStore.Save(context.Background(), record) + if err != nil { + return nil, fmt.Errorf("failed to create robot: %w", err) + } + + // Refresh cache if manager is running + mgr, err := getManager() + if err == nil && mgr != nil { + // Load the new robot into cache + _, _ = mgr.Cache().LoadByID(ctx, req.MemberID) + } + + // Return the created robot as response + return GetRobotResponse(ctx, req.MemberID) +} + +// UpdateRobot updates an existing robot member +// Calls store.RobotStore.Save() and refreshes cache +func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (*RobotResponse, error) { + if memberID == "" { + return nil, fmt.Errorf("member_id is required") + } + + // Get existing record + existing, err := robotStore.Get(context.Background(), memberID) + if err != nil { + return nil, fmt.Errorf("failed to get robot: %w", err) + } + if existing == nil { + return nil, types.ErrRobotNotFound + } + + // Apply updates - only non-nil fields are updated + // Profile + if req.DisplayName != nil { + existing.DisplayName = *req.DisplayName + } + if req.Bio != nil { + existing.Bio = *req.Bio + } + if req.Avatar != nil { + existing.Avatar = *req.Avatar + } + + // Identity & Role + if req.SystemPrompt != nil { + existing.SystemPrompt = *req.SystemPrompt + } + if req.RoleID != nil { + existing.RoleID = *req.RoleID + } + if req.ManagerID != nil { + existing.ManagerID = *req.ManagerID + } + + // Status + if req.Status != nil { + existing.Status = *req.Status + } + if req.RobotStatus != nil { + existing.RobotStatus = *req.RobotStatus + } + if req.AutonomousMode != nil { + existing.AutonomousMode = *req.AutonomousMode + } + + // Communication + if req.RobotEmail != nil { + existing.RobotEmail = *req.RobotEmail + } + if req.AuthorizedSenders != nil { + existing.AuthorizedSenders = req.AuthorizedSenders + } + if req.EmailFilterRules != nil { + existing.EmailFilterRules = req.EmailFilterRules + } + + // Capabilities + if req.RobotConfig != nil { + existing.RobotConfig = req.RobotConfig + } + if req.Agents != nil { + existing.Agents = req.Agents + } + if req.MCPServers != nil { + existing.MCPServers = req.MCPServers + } + if req.LanguageModel != nil { + existing.LanguageModel = *req.LanguageModel + } + + // Limits + if req.CostLimit != nil { + existing.CostLimit = *req.CostLimit + } + + // Apply Yao permission fields if provided (update scope) + if req.AuthScope != nil { + existing.YaoUpdatedBy = req.AuthScope.UpdatedBy + // Team and Tenant are typically set on create, not update + // But allow override if explicitly provided + if req.AuthScope.TeamID != "" { + existing.YaoTeamID = req.AuthScope.TeamID + } + if req.AuthScope.TenantID != "" { + existing.YaoTenantID = req.AuthScope.TenantID + } + } + + // Save to database + err = robotStore.Save(context.Background(), existing) + if err != nil { + return nil, fmt.Errorf("failed to update robot: %w", err) + } + + // Refresh cache if manager is running + mgr, err := getManager() + if err == nil && mgr != nil { + // Remove old entry and reload + mgr.Cache().Remove(memberID) + _, _ = mgr.Cache().LoadByID(ctx, memberID) + } + + // Return the updated robot as response + return GetRobotResponse(ctx, memberID) +} + +// RemoveRobot deletes a robot member +// Calls store.RobotStore.Delete() and invalidates cache +func RemoveRobot(ctx *types.Context, memberID string) error { + if memberID == "" { + return fmt.Errorf("member_id is required") + } + + // Check if robot exists + existing, err := robotStore.Get(context.Background(), memberID) + if err != nil { + return fmt.Errorf("failed to get robot: %w", err) + } + if existing == nil { + return types.ErrRobotNotFound + } + + // Check if robot has running executions + mgr, err := getManager() + if err == nil && mgr != nil { + robot := mgr.Cache().Get(memberID) + if robot != nil && robot.RunningCount() > 0 { + return fmt.Errorf("cannot delete robot with running executions") + } + } + + // Delete from database + err = robotStore.Delete(context.Background(), memberID) + if err != nil { + return fmt.Errorf("failed to delete robot: %w", err) + } + + // Invalidate cache if manager is running + if mgr != nil { + mgr.Cache().Remove(memberID) + } + + return nil +} + +// GetRobotResponse retrieves a robot and converts to API response format +func GetRobotResponse(ctx *types.Context, memberID string) (*RobotResponse, error) { + record, err := robotStore.Get(context.Background(), memberID) + if err != nil { + return nil, fmt.Errorf("failed to get robot: %w", err) + } + if record == nil { + return nil, types.ErrRobotNotFound + } + + return recordToResponse(record), nil +} + +// recordToResponse converts a store.RobotRecord to API RobotResponse +func recordToResponse(record *store.RobotRecord) *RobotResponse { + return &RobotResponse{ + ID: record.ID, + MemberID: record.MemberID, + TeamID: record.TeamID, + Status: record.Status, + RobotStatus: record.RobotStatus, + AutonomousMode: record.AutonomousMode, + + DisplayName: record.DisplayName, + Bio: record.Bio, + Avatar: record.Avatar, + + SystemPrompt: record.SystemPrompt, + RoleID: record.RoleID, + ManagerID: record.ManagerID, + + RobotEmail: record.RobotEmail, + AuthorizedSenders: record.AuthorizedSenders, + EmailFilterRules: record.EmailFilterRules, + + RobotConfig: record.RobotConfig, + Agents: record.Agents, + MCPServers: record.MCPServers, + LanguageModel: record.LanguageModel, + + CostLimit: record.CostLimit, + InvitedBy: record.InvitedBy, + JoinedAt: record.JoinedAt, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + } +} diff --git a/agent/robot/api/robot_test.go b/agent/robot/api/robot_test.go index 18abd133..02607de8 100644 --- a/agent/robot/api/robot_test.go +++ b/agent/robot/api/robot_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/yaoapp/yao/agent/robot/api" "github.com/yaoapp/yao/agent/robot/types" "github.com/yaoapp/yao/agent/testutils" @@ -100,3 +101,370 @@ func TestGetRobotStatusValidation(t *testing.T) { assert.Nil(t, status) }) } + +// ==================== Robot CRUD API Tests ==================== + +// TestCreateRobotValidation tests parameter validation for CreateRobot +func TestCreateRobotValidation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), nil) + + t.Run("returns_error_for_empty_member_id", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "", + TeamID: "team_001", + DisplayName: "Test Robot", + } + result, err := api.CreateRobot(ctx, req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "member_id is required") + }) + + t.Run("returns_error_for_empty_team_id", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "robot_test_001", + TeamID: "", + DisplayName: "Test Robot", + } + result, err := api.CreateRobot(ctx, req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "team_id is required") + }) + + t.Run("returns_error_for_empty_display_name", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "robot_test_001", + TeamID: "team_001", + DisplayName: "", + } + result, err := api.CreateRobot(ctx, req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "display_name is required") + }) +} + +// TestCreateRobot tests the CreateRobot API function +func TestCreateRobot(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Cleanup before and after + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + ctx := types.NewContext(context.Background(), nil) + + t.Run("creates_robot_with_required_fields", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "api_robot_create_001", + TeamID: "api_team_001", + DisplayName: "API Test Robot", + } + + result, err := api.CreateRobot(ctx, req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "api_robot_create_001", result.MemberID) + assert.Equal(t, "api_team_001", result.TeamID) + assert.Equal(t, "API Test Robot", result.DisplayName) + assert.Equal(t, "active", result.Status) + assert.Equal(t, "idle", result.RobotStatus) + }) + + t.Run("creates_robot_with_all_fields", func(t *testing.T) { + autonomousMode := true + req := &api.CreateRobotRequest{ + MemberID: "api_robot_create_002", + TeamID: "api_team_002", + DisplayName: "Full Robot", + Bio: "A fully configured robot", + SystemPrompt: "You are a helpful assistant", + Avatar: "https://example.com/avatar.png", + RoleID: "admin", + ManagerID: "user_001", + AutonomousMode: &autonomousMode, + RobotEmail: "fullrobot@test.com", + LanguageModel: "gpt-4", + CostLimit: 100.0, + RobotConfig: map[string]interface{}{ + "clock_mode": "on", + "max_concurrent": 3, + }, + } + + result, err := api.CreateRobot(ctx, req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "api_robot_create_002", result.MemberID) + assert.Equal(t, "Full Robot", result.DisplayName) + assert.Equal(t, "A fully configured robot", result.Bio) + assert.Equal(t, "You are a helpful assistant", result.SystemPrompt) + assert.Equal(t, "admin", result.RoleID) + assert.True(t, result.AutonomousMode) + assert.Equal(t, "fullrobot@test.com", result.RobotEmail) + assert.Equal(t, "gpt-4", result.LanguageModel) + assert.Equal(t, 100.0, result.CostLimit) + }) + + t.Run("creates_robot_with_auth_scope", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "api_robot_create_003", + TeamID: "api_team_003", + DisplayName: "Robot with Auth", + AuthScope: &api.AuthScope{ + CreatedBy: "user_123", + TeamID: "perm_team_001", + TenantID: "tenant_001", + }, + } + + result, err := api.CreateRobot(ctx, req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "api_robot_create_003", result.MemberID) + // InvitedBy should be set from AuthScope.CreatedBy + assert.Equal(t, "user_123", result.InvitedBy) + }) + + t.Run("returns_error_for_duplicate_member_id", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "api_robot_create_001", // Already created above + TeamID: "api_team_001", + DisplayName: "Duplicate Robot", + } + + result, err := api.CreateRobot(ctx, req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "already exists") + }) +} + +// TestUpdateRobot tests the UpdateRobot API function +func TestUpdateRobot(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + ctx := types.NewContext(context.Background(), nil) + + // Create a robot to update + createReq := &api.CreateRobotRequest{ + MemberID: "api_robot_update_001", + TeamID: "api_team_update", + DisplayName: "Original Name", + Bio: "Original bio", + } + _, err := api.CreateRobot(ctx, createReq) + require.NoError(t, err) + + t.Run("returns_error_for_empty_member_id", func(t *testing.T) { + req := &api.UpdateRobotRequest{} + result, err := api.UpdateRobot(ctx, "", req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "member_id is required") + }) + + t.Run("returns_error_for_non_existent_robot", func(t *testing.T) { + newName := "New Name" + req := &api.UpdateRobotRequest{ + DisplayName: &newName, + } + result, err := api.UpdateRobot(ctx, "non_existent_robot", req) + assert.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("updates_display_name", func(t *testing.T) { + newName := "Updated Name" + req := &api.UpdateRobotRequest{ + DisplayName: &newName, + } + + result, err := api.UpdateRobot(ctx, "api_robot_update_001", req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "Updated Name", result.DisplayName) + // Bio should be unchanged + assert.Equal(t, "Original bio", result.Bio) + }) + + t.Run("updates_multiple_fields", func(t *testing.T) { + newBio := "New bio description" + newPrompt := "Updated system prompt" + autonomousMode := true + + req := &api.UpdateRobotRequest{ + Bio: &newBio, + SystemPrompt: &newPrompt, + AutonomousMode: &autonomousMode, + } + + result, err := api.UpdateRobot(ctx, "api_robot_update_001", req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "New bio description", result.Bio) + assert.Equal(t, "Updated system prompt", result.SystemPrompt) + assert.True(t, result.AutonomousMode) + }) + + t.Run("updates_robot_status", func(t *testing.T) { + newStatus := "working" + req := &api.UpdateRobotRequest{ + RobotStatus: &newStatus, + } + + result, err := api.UpdateRobot(ctx, "api_robot_update_001", req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "working", result.RobotStatus) + }) + + t.Run("updates_config", func(t *testing.T) { + newConfig := map[string]interface{}{ + "clock_mode": "off", + "max_concurrent": 5, + } + req := &api.UpdateRobotRequest{ + RobotConfig: newConfig, + } + + result, err := api.UpdateRobot(ctx, "api_robot_update_001", req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.NotNil(t, result.RobotConfig) + }) +} + +// TestRemoveRobot tests the RemoveRobot API function +func TestRemoveRobot(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + ctx := types.NewContext(context.Background(), nil) + + t.Run("returns_error_for_empty_member_id", func(t *testing.T) { + err := api.RemoveRobot(ctx, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "member_id is required") + }) + + t.Run("returns_error_for_non_existent_robot", func(t *testing.T) { + err := api.RemoveRobot(ctx, "non_existent_robot") + assert.Error(t, err) + }) + + t.Run("removes_existing_robot", func(t *testing.T) { + // Create a robot + createReq := &api.CreateRobotRequest{ + MemberID: "api_robot_remove_001", + TeamID: "api_team_remove", + DisplayName: "Robot to Remove", + } + _, err := api.CreateRobot(ctx, createReq) + require.NoError(t, err) + + // Verify it exists + robot, err := api.GetRobot(ctx, "api_robot_remove_001") + require.NoError(t, err) + require.NotNil(t, robot) + + // Remove it + err = api.RemoveRobot(ctx, "api_robot_remove_001") + require.NoError(t, err) + + // Verify it's gone + robot, err = api.GetRobot(ctx, "api_robot_remove_001") + assert.Error(t, err) // Should return error for non-existent + }) +} + +// TestGetRobotResponse tests the GetRobotResponse API function +func TestGetRobotResponse(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + ctx := types.NewContext(context.Background(), nil) + + // Create a robot + autonomousMode := true + createReq := &api.CreateRobotRequest{ + MemberID: "api_robot_response_001", + TeamID: "api_team_response", + DisplayName: "Response Test Robot", + Bio: "Test bio for response", + SystemPrompt: "Test prompt", + AutonomousMode: &autonomousMode, + RobotEmail: "response@test.com", + CostLimit: 50.0, + } + _, err := api.CreateRobot(ctx, createReq) + require.NoError(t, err) + + t.Run("returns_robot_response_format", func(t *testing.T) { + result, err := api.GetRobotResponse(ctx, "api_robot_response_001") + require.NoError(t, err) + require.NotNil(t, result) + + // Verify all fields are present in response + assert.Equal(t, "api_robot_response_001", result.MemberID) + assert.Equal(t, "api_team_response", result.TeamID) + assert.Equal(t, "Response Test Robot", result.DisplayName) + assert.Equal(t, "Test bio for response", result.Bio) + assert.Equal(t, "Test prompt", result.SystemPrompt) + assert.True(t, result.AutonomousMode) + assert.Equal(t, "response@test.com", result.RobotEmail) + assert.Equal(t, 50.0, result.CostLimit) + assert.Equal(t, "active", result.Status) + assert.Equal(t, "idle", result.RobotStatus) + }) + + t.Run("returns_error_for_non_existent", func(t *testing.T) { + result, err := api.GetRobotResponse(ctx, "non_existent") + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +// Note: cleanupAPITestRobots is defined in api_test.go (shared helper) diff --git a/agent/robot/api/types.go b/agent/robot/api/types.go index 4d60f432..cca54647 100644 --- a/agent/robot/api/types.go +++ b/agent/robot/api/types.go @@ -31,6 +31,7 @@ type RobotState struct { MemberID string `json:"member_id"` TeamID string `json:"team_id"` DisplayName string `json:"display_name"` + Bio string `json:"bio,omitempty"` Status types.RobotStatus `json:"status"` Running int `json:"running"` MaxRunning int `json:"max_running"` @@ -103,6 +104,134 @@ type ExecutionResult struct { PageSize int `json:"pagesize"` } +// ==================== CRUD Types ==================== + +// AuthScope contains Yao permission fields for data scoping +// These fields are used by Yao's permission system (when model has permission: true) +type AuthScope struct { + CreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id + UpdatedBy string `json:"__yao_updated_by,omitempty"` // Updater user_id + TeamID string `json:"__yao_team_id,omitempty"` // Permission team scope + TenantID string `json:"__yao_tenant_id,omitempty"` // Permission tenant scope +} + +// CreateRobotRequest - request for CreateRobot() +type CreateRobotRequest struct { + // Required fields + MemberID string `json:"member_id"` // Unique robot identifier + TeamID string `json:"team_id"` // Team ID + + // Profile + DisplayName string `json:"display_name,omitempty"` // Display name + Bio string `json:"bio,omitempty"` // Robot description + Avatar string `json:"avatar,omitempty"` // Avatar URL + + // Identity & Role + SystemPrompt string `json:"system_prompt,omitempty"` // System prompt + RoleID string `json:"role_id,omitempty"` // Role within team + ManagerID string `json:"manager_id,omitempty"` // Direct manager user_id + + // Status + Status string `json:"status,omitempty"` // Member status: active | inactive | pending | suspended + RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle | working | paused | error | maintenance + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled + + // Communication + RobotEmail string `json:"robot_email,omitempty"` // Robot email address + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array) + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array) + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON + Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array) + MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array) + LanguageModel string `json:"language_model,omitempty"` // Language model name + + // Limits + CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD + + // Auth scope (optional, used by OpenAPI layer via WithCreateScope) + AuthScope *AuthScope `json:"auth_scope,omitempty"` +} + +// UpdateRobotRequest - request for UpdateRobot() +type UpdateRobotRequest struct { + // Profile + DisplayName *string `json:"display_name,omitempty"` // Display name + Bio *string `json:"bio,omitempty"` // Robot description + Avatar *string `json:"avatar,omitempty"` // Avatar URL + + // Identity & Role + SystemPrompt *string `json:"system_prompt,omitempty"` // System prompt + RoleID *string `json:"role_id,omitempty"` // Role within team + ManagerID *string `json:"manager_id,omitempty"` // Direct manager user_id + + // Status + Status *string `json:"status,omitempty"` // Member status + RobotStatus *string `json:"robot_status,omitempty"` // Robot status + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode + + // Communication + RobotEmail *string `json:"robot_email,omitempty"` // Robot email address + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON + Agents interface{} `json:"agents,omitempty"` // Accessible agents + MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers + LanguageModel *string `json:"language_model,omitempty"` // Language model name + + // Limits + CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD + + // Auth scope (optional, used by OpenAPI layer via WithUpdateScope) + AuthScope *AuthScope `json:"auth_scope,omitempty"` +} + +// RobotResponse - response containing robot details for API +type RobotResponse struct { + // Basic + ID int64 `json:"id,omitempty"` + MemberID string `json:"member_id"` + TeamID string `json:"team_id"` + Status string `json:"status"` + RobotStatus string `json:"robot_status"` + AutonomousMode bool `json:"autonomous_mode"` + + // Profile + DisplayName string `json:"display_name"` + Bio string `json:"bio,omitempty"` + Avatar string `json:"avatar,omitempty"` + + // Identity & Role + SystemPrompt string `json:"system_prompt,omitempty"` + RoleID string `json:"role_id,omitempty"` + ManagerID string `json:"manager_id,omitempty"` + + // Communication + RobotEmail string `json:"robot_email,omitempty"` + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` + Agents interface{} `json:"agents,omitempty"` + MCPServers interface{} `json:"mcp_servers,omitempty"` + LanguageModel string `json:"language_model,omitempty"` + + // Limits + CostLimit float64 `json:"cost_limit,omitempty"` + + // Ownership & Audit + InvitedBy string `json:"invited_by,omitempty"` + JoinedAt *time.Time `json:"joined_at,omitempty"` + + // Timestamps + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + // ==================== Helper Functions ==================== // applyDefaults applies default values to ListQuery diff --git a/agent/robot/cache/load.go b/agent/robot/cache/load.go index 60c832b6..25189ec7 100644 --- a/agent/robot/cache/load.go +++ b/agent/robot/cache/load.go @@ -18,10 +18,12 @@ var memberFields = []interface{}{ "member_id", "team_id", "display_name", + "bio", "system_prompt", "robot_status", "autonomous_mode", "robot_config", + "robot_email", } // SetMemberModel sets the member model name diff --git a/agent/robot/store/robot.go b/agent/robot/store/robot.go new file mode 100644 index 00000000..19068512 --- /dev/null +++ b/agent/robot/store/robot.go @@ -0,0 +1,634 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/yaoapp/gou/model" + "github.com/yaoapp/kun/maps" + "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/robot/utils" +) + +// RobotRecord - persistent storage for robot member +// Maps to __yao.member model +type RobotRecord struct { + ID int64 `json:"id,omitempty"` // Auto-increment primary key + MemberID string `json:"member_id"` // Unique robot identifier + TeamID string `json:"team_id"` // Team ID + MemberType string `json:"member_type"` // Always "robot" for robots + Status string `json:"status"` // Member status: active | inactive | pending | suspended + RobotStatus string `json:"robot_status"` // Robot status: idle | working | paused | error | maintenance + AutonomousMode bool `json:"autonomous_mode"` // Whether autonomous mode is enabled + + // Profile + DisplayName string `json:"display_name"` // Display name + Bio string `json:"bio,omitempty"` // Robot description + Avatar string `json:"avatar,omitempty"` + + // Identity & Role + SystemPrompt string `json:"system_prompt"` // System prompt + RoleID string `json:"role_id"` // Role within team + ManagerID string `json:"manager_id"` // Direct manager user_id (who manages this robot) + + // Communication + RobotEmail string `json:"robot_email"` // Robot email address + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array) + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array) + + // Capabilities + RobotConfig interface{} `json:"robot_config"` // Robot config JSON + Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array) + MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array) + LanguageModel string `json:"language_model,omitempty"` // Language model name + + // Limits + CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD + + // Ownership & Audit + InvitedBy string `json:"invited_by,omitempty"` // Who created/added this robot + JoinedAt *time.Time `json:"joined_at,omitempty"` // When robot was created + + // Timestamps + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + + // Yao Permission Fields (automatically handled by Yao model when permission:true) + // These fields are passed through to the model layer for permission control + YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id (set on create) + YaoUpdatedBy string `json:"__yao_updated_by,omitempty"` // Updater user_id (set on update) + YaoTeamID string `json:"__yao_team_id,omitempty"` // Permission team scope + YaoTenantID string `json:"__yao_tenant_id,omitempty"` // Permission tenant scope +} + +// RobotListOptions - options for listing robot records +type RobotListOptions struct { + TeamID string `json:"team_id,omitempty"` + Status types.RobotStatus `json:"status,omitempty"` + Keywords string `json:"keywords,omitempty"` // Search in display_name + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"pagesize,omitempty"` + OrderBy string `json:"order_by,omitempty"` +} + +// RobotStore - persistent storage for robot members +type RobotStore struct { + modelID string +} + +// NewRobotStore creates a new robot store instance +func NewRobotStore() *RobotStore { + return &RobotStore{ + modelID: "__yao.member", + } +} + +// robotFields are the fields to select when loading robots +var robotFields = []interface{}{ + // Basic + "id", + "member_id", + "team_id", + "member_type", + "status", + "robot_status", + "autonomous_mode", + + // Profile + "display_name", + "bio", + "avatar", + + // Identity & Role + "system_prompt", + "role_id", + "manager_id", + + // Communication + "robot_email", + "authorized_senders", + "email_filter_rules", + + // Capabilities + "robot_config", + "agents", + "mcp_servers", + "language_model", + + // Limits + "cost_limit", + + // Ownership & Audit + "invited_by", + "joined_at", + + // Timestamps + "created_at", + "updated_at", +} + +// Save creates or updates a robot member record +func (s *RobotStore) Save(ctx context.Context, record *RobotRecord) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + // Ensure member_type is robot + record.MemberType = "robot" + + data := s.recordToMap(record) + + // Check if record exists by member_id + existing, err := s.Get(ctx, record.MemberID) + if err == nil && existing != nil { + // Update existing record + _, err = mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: record.MemberID}, + }, + }, + data, + ) + if err != nil { + return fmt.Errorf("failed to update robot record: %w", err) + } + return nil + } + + // Create new record + _, err = mod.Create(data) + if err != nil { + return fmt.Errorf("failed to create robot record: %w", err) + } + return nil +} + +// Get retrieves a robot record by member_id +func (s *RobotStore) Get(ctx context.Context, memberID string) (*RobotRecord, error) { + mod := model.Select(s.modelID) + if mod == nil { + return nil, fmt.Errorf("model %s not found", s.modelID) + } + + rows, err := mod.Get(model.QueryParam{ + Select: robotFields, + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + {Column: "member_type", Value: "robot"}, + }, + Limit: 1, + }) + if err != nil { + return nil, fmt.Errorf("failed to get robot record: %w", err) + } + + if len(rows) == 0 { + return nil, nil + } + + return s.mapToRecord(rows[0]) +} + +// List retrieves robot records with filters +func (s *RobotStore) List(ctx context.Context, opts *RobotListOptions) ([]*RobotRecord, int, error) { + mod := model.Select(s.modelID) + if mod == nil { + return nil, 0, fmt.Errorf("model %s not found", s.modelID) + } + + // Build where conditions - only require member_type=robot + wheres := []model.QueryWhere{ + {Column: "member_type", Value: "robot"}, + } + + if opts != nil { + if opts.TeamID != "" { + wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID}) + } + if opts.Status != "" { + wheres = append(wheres, model.QueryWhere{Column: "robot_status", Value: string(opts.Status)}) + } + if opts.Keywords != "" { + wheres = append(wheres, model.QueryWhere{ + Column: "display_name", + OP: "like", + Value: "%" + opts.Keywords + "%", + }) + } + } + + // Build order + orders := []model.QueryOrder{} + if opts != nil && opts.OrderBy != "" { + orders = append(orders, model.QueryOrder{Column: opts.OrderBy}) + } else { + orders = append(orders, model.QueryOrder{Column: "created_at", Option: "desc"}) + } + + // Determine pagination + page := 1 + pageSize := 100 + if opts != nil { + if opts.Page > 0 { + page = opts.Page + } + if opts.PageSize > 0 { + pageSize = opts.PageSize + } + // Limit overrides PageSize for simple limit queries + if opts.Limit > 0 { + pageSize = opts.Limit + } + } + + // Execute paginated query + result, err := mod.Paginate(model.QueryParam{ + Select: robotFields, + Wheres: wheres, + Orders: orders, + }, page, pageSize) + if err != nil { + return nil, 0, fmt.Errorf("failed to list robots: %w", err) + } + + // Get total count + total := 0 + if t, ok := result.Get("total").(int); ok { + total = t + } + + // Parse records + records := []*RobotRecord{} + data := result.Get("data") + switch rows := data.(type) { + case []maps.MapStr: + for _, row := range rows { + record, err := s.mapToRecord(map[string]interface{}(row)) + if err != nil { + continue // skip invalid records + } + records = append(records, record) + } + case []map[string]interface{}: + for _, row := range rows { + record, err := s.mapToRecord(row) + if err != nil { + continue // skip invalid records + } + records = append(records, record) + } + } + + return records, total, nil +} + +// Delete removes a robot member by member_id +func (s *RobotStore) Delete(ctx context.Context, memberID string) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + _, err := mod.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + {Column: "member_type", Value: "robot"}, + }, + }) + if err != nil { + return fmt.Errorf("failed to delete robot record: %w", err) + } + + return nil +} + +// UpdateConfig updates only the robot_config field +func (s *RobotStore) UpdateConfig(ctx context.Context, memberID string, config interface{}) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + data := map[string]interface{}{ + "robot_config": config, + } + + _, err := mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + {Column: "member_type", Value: "robot"}, + }, + }, + data, + ) + if err != nil { + return fmt.Errorf("failed to update robot config: %w", err) + } + + return nil +} + +// UpdateStatus updates the robot_status field +func (s *RobotStore) UpdateStatus(ctx context.Context, memberID string, status types.RobotStatus) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + data := map[string]interface{}{ + "robot_status": string(status), + } + + _, err := mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + {Column: "member_type", Value: "robot"}, + }, + }, + data, + ) + if err != nil { + return fmt.Errorf("failed to update robot status: %w", err) + } + + return nil +} + +// recordToMap converts RobotRecord to map for model operations +func (s *RobotStore) recordToMap(record *RobotRecord) map[string]interface{} { + data := map[string]interface{}{ + // Required fields + "member_id": record.MemberID, + "team_id": record.TeamID, + "member_type": "robot", + "autonomous_mode": record.AutonomousMode, + } + + // Status + if record.Status != "" { + data["status"] = record.Status + } else { + data["status"] = "active" + } + if record.RobotStatus != "" { + data["robot_status"] = record.RobotStatus + } else { + data["robot_status"] = "idle" + } + + // Profile + if record.DisplayName != "" { + data["display_name"] = record.DisplayName + } + if record.Bio != "" { + data["bio"] = record.Bio + } + if record.Avatar != "" { + data["avatar"] = record.Avatar + } + + // Identity & Role + if record.SystemPrompt != "" { + data["system_prompt"] = record.SystemPrompt + } + if record.RoleID != "" { + data["role_id"] = record.RoleID + } + if record.ManagerID != "" { + data["manager_id"] = record.ManagerID + } + + // Communication + if record.RobotEmail != "" { + data["robot_email"] = record.RobotEmail + } + if record.AuthorizedSenders != nil { + data["authorized_senders"] = record.AuthorizedSenders + } + if record.EmailFilterRules != nil { + data["email_filter_rules"] = record.EmailFilterRules + } + + // Capabilities + if record.RobotConfig != nil { + data["robot_config"] = record.RobotConfig + } + if record.Agents != nil { + data["agents"] = record.Agents + } + if record.MCPServers != nil { + data["mcp_servers"] = record.MCPServers + } + if record.LanguageModel != "" { + data["language_model"] = record.LanguageModel + } + + // Limits + if record.CostLimit > 0 { + data["cost_limit"] = record.CostLimit + } + + // Ownership & Audit + if record.InvitedBy != "" { + data["invited_by"] = record.InvitedBy + } + if record.JoinedAt != nil { + // Format time for Gou model (expects string format) + data["joined_at"] = record.JoinedAt.Format("2006-01-02 15:04:05") + } + + // Yao Permission Fields - pass through for model layer + if record.YaoCreatedBy != "" { + data["__yao_created_by"] = record.YaoCreatedBy + } + if record.YaoUpdatedBy != "" { + data["__yao_updated_by"] = record.YaoUpdatedBy + } + if record.YaoTeamID != "" { + data["__yao_team_id"] = record.YaoTeamID + } + if record.YaoTenantID != "" { + data["__yao_tenant_id"] = record.YaoTenantID + } + + return data +} + +// mapToRecord converts a model row to RobotRecord +func (s *RobotStore) mapToRecord(row map[string]interface{}) (*RobotRecord, error) { + record := &RobotRecord{} + + // Basic fields + if v, ok := row["id"]; ok { + switch id := v.(type) { + case float64: + record.ID = int64(id) + case int64: + record.ID = id + case int: + record.ID = int64(id) + } + } + if v, ok := row["member_id"].(string); ok { + record.MemberID = v + } + if v, ok := row["team_id"].(string); ok { + record.TeamID = v + } + if v, ok := row["member_type"].(string); ok { + record.MemberType = v + } + if v, ok := row["status"].(string); ok { + record.Status = v + } + if v, ok := row["robot_status"].(string); ok { + record.RobotStatus = v + } + if v, ok := row["autonomous_mode"]; ok { + record.AutonomousMode = utils.ToBool(v) + } + + // Profile + if v, ok := row["display_name"].(string); ok { + record.DisplayName = v + } + if v, ok := row["bio"].(string); ok { + record.Bio = v + } + if v, ok := row["avatar"].(string); ok { + record.Avatar = v + } + + // Identity & Role + if v, ok := row["system_prompt"].(string); ok { + record.SystemPrompt = v + } + if v, ok := row["role_id"].(string); ok { + record.RoleID = v + } + if v, ok := row["manager_id"].(string); ok { + record.ManagerID = v + } + + // Communication + if v, ok := row["robot_email"].(string); ok { + record.RobotEmail = v + } + if v := row["authorized_senders"]; v != nil { + record.AuthorizedSenders = utils.ToJSONValue(v) + } + if v := row["email_filter_rules"]; v != nil { + record.EmailFilterRules = utils.ToJSONValue(v) + } + + // Capabilities + if v := row["robot_config"]; v != nil { + record.RobotConfig = utils.ToJSONValue(v) + } + if v := row["agents"]; v != nil { + record.Agents = utils.ToJSONValue(v) + } + if v := row["mcp_servers"]; v != nil { + record.MCPServers = utils.ToJSONValue(v) + } + if v, ok := row["language_model"].(string); ok { + record.LanguageModel = v + } + + // Limits + if v := row["cost_limit"]; v != nil { + record.CostLimit = utils.ToFloat64(v) + } + + // Ownership & Audit + if v, ok := row["invited_by"].(string); ok { + record.InvitedBy = v + } + if v := row["joined_at"]; v != nil { + record.JoinedAt = utils.ToTimestamp(v) + } + + // Timestamps + if v := row["created_at"]; v != nil { + record.CreatedAt = utils.ToTimestamp(v) + } + if v := row["updated_at"]; v != nil { + record.UpdatedAt = utils.ToTimestamp(v) + } + + // Yao Permission Fields + if v, ok := row["__yao_created_by"].(string); ok { + record.YaoCreatedBy = v + } + if v, ok := row["__yao_updated_by"].(string); ok { + record.YaoUpdatedBy = v + } + if v, ok := row["__yao_team_id"].(string); ok { + record.YaoTeamID = v + } + if v, ok := row["__yao_tenant_id"].(string); ok { + record.YaoTenantID = v + } + + return record, nil +} + +// ToRobot converts a RobotRecord to types.Robot +func (r *RobotRecord) ToRobot() (*types.Robot, error) { + robot := &types.Robot{ + MemberID: r.MemberID, + TeamID: r.TeamID, + DisplayName: r.DisplayName, + Bio: r.Bio, + SystemPrompt: r.SystemPrompt, + AutonomousMode: r.AutonomousMode, + RobotEmail: r.RobotEmail, + } + + // Parse robot_status + if r.RobotStatus != "" { + robot.Status = types.RobotStatus(r.RobotStatus) + } else { + robot.Status = types.RobotIdle + } + + // Parse robot_config + if r.RobotConfig != nil { + config, err := types.ParseConfig(r.RobotConfig) + if err != nil { + return nil, fmt.Errorf("failed to parse robot_config: %w", err) + } + robot.Config = config + } + + return robot, nil +} + +// FromRobot creates a RobotRecord from types.Robot +func FromRobot(robot *types.Robot) *RobotRecord { + record := &RobotRecord{ + MemberID: robot.MemberID, + TeamID: robot.TeamID, + DisplayName: robot.DisplayName, + Bio: robot.Bio, + SystemPrompt: robot.SystemPrompt, + RobotStatus: string(robot.Status), + AutonomousMode: robot.AutonomousMode, + RobotEmail: robot.RobotEmail, + MemberType: "robot", + Status: "active", + } + + if robot.Config != nil { + record.RobotConfig = robot.Config + } + + return record +} diff --git a/agent/robot/store/robot_test.go b/agent/robot/store/robot_test.go new file mode 100644 index 00000000..0c392861 --- /dev/null +++ b/agent/robot/store/robot_test.go @@ -0,0 +1,578 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/yao/agent/robot/store" + "github.com/yaoapp/yao/agent/testutils" +) + +// TestRobotStoreSave tests creating and updating robot records +func TestRobotStoreSave(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + t.Run("creates_new_robot_record", func(t *testing.T) { + now := time.Now() + record := &store.RobotRecord{ + MemberID: "robot_test_save_001", + TeamID: "team_test_001", + DisplayName: "Test Robot 001", + Bio: "A test robot for save operations", + SystemPrompt: "You are a helpful assistant", + Status: "active", + RobotStatus: "idle", + AutonomousMode: true, + RobotEmail: "robot001@test.com", + JoinedAt: &now, + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + // Verify it was created + saved, err := s.Get(ctx, "robot_test_save_001") + require.NoError(t, err) + require.NotNil(t, saved) + + assert.Equal(t, "robot_test_save_001", saved.MemberID) + assert.Equal(t, "team_test_001", saved.TeamID) + assert.Equal(t, "Test Robot 001", saved.DisplayName) + assert.Equal(t, "A test robot for save operations", saved.Bio) + assert.Equal(t, "You are a helpful assistant", saved.SystemPrompt) + assert.Equal(t, "active", saved.Status) + assert.Equal(t, "idle", saved.RobotStatus) + assert.True(t, saved.AutonomousMode) + assert.Equal(t, "robot001@test.com", saved.RobotEmail) + assert.Equal(t, "robot", saved.MemberType) + assert.NotNil(t, saved.JoinedAt) + }) + + t.Run("updates_existing_robot_record", func(t *testing.T) { + // First create a record + record := &store.RobotRecord{ + MemberID: "robot_test_save_002", + TeamID: "team_test_002", + DisplayName: "Original Name", + Status: "active", + RobotStatus: "idle", + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + // Update the record + record.DisplayName = "Updated Name" + record.Bio = "Updated bio" + record.RobotStatus = "working" + + err = s.Save(ctx, record) + require.NoError(t, err) + + // Verify the update + saved, err := s.Get(ctx, "robot_test_save_002") + require.NoError(t, err) + require.NotNil(t, saved) + + assert.Equal(t, "Updated Name", saved.DisplayName) + assert.Equal(t, "Updated bio", saved.Bio) + assert.Equal(t, "working", saved.RobotStatus) + }) + + t.Run("saves_robot_with_config", func(t *testing.T) { + record := &store.RobotRecord{ + MemberID: "robot_test_save_003", + TeamID: "team_test_003", + DisplayName: "Robot with Config", + Status: "active", + RobotStatus: "idle", + RobotConfig: map[string]interface{}{ + "clock_mode": "on", + "max_concurrent": 3, + "timeout_seconds": 300, + }, + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_save_003") + require.NoError(t, err) + require.NotNil(t, saved) + assert.NotNil(t, saved.RobotConfig) + }) + + t.Run("saves_robot_with_permission_fields", func(t *testing.T) { + record := &store.RobotRecord{ + MemberID: "robot_test_save_004", + TeamID: "team_test_004", + DisplayName: "Robot with Perms", + Status: "active", + RobotStatus: "idle", + YaoCreatedBy: "user_001", + YaoTeamID: "team_001", + YaoTenantID: "tenant_001", + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + // Yao permission fields are handled by the model layer + saved, err := s.Get(ctx, "robot_test_save_004") + require.NoError(t, err) + require.NotNil(t, saved) + }) +} + +// TestRobotStoreGet tests retrieving robot records +func TestRobotStoreGet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + // Create a test record + setupTestRobot(t, s, ctx) + + t.Run("returns_existing_record", func(t *testing.T) { + record, err := s.Get(ctx, "robot_test_get_001") + require.NoError(t, err) + require.NotNil(t, record) + + assert.Equal(t, "robot_test_get_001", record.MemberID) + assert.Equal(t, "team_test_get", record.TeamID) + assert.Equal(t, "Test Robot Get", record.DisplayName) + assert.Equal(t, "Test robot description", record.Bio) + assert.Equal(t, "robot", record.MemberType) + assert.Equal(t, "active", record.Status) + assert.Equal(t, "idle", record.RobotStatus) + }) + + t.Run("returns_nil_for_non_existent_record", func(t *testing.T) { + record, err := s.Get(ctx, "robot_non_existent") + require.NoError(t, err) + assert.Nil(t, record) + }) + + t.Run("ignores_non_robot_members", func(t *testing.T) { + // Get should only return member_type="robot" records + record, err := s.Get(ctx, "robot_test_get_001") + require.NoError(t, err) + require.NotNil(t, record) + assert.Equal(t, "robot", record.MemberType) + }) +} + +// TestRobotStoreList tests listing robot records with filters +func TestRobotStoreList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + // Create multiple test records + setupTestRobotsForList(t, s, ctx) + + t.Run("lists_all_robot_records", func(t *testing.T) { + // List with keywords filter to only get our test records + // Test robots have display names like "Robot Alpha", "Robot Beta", etc. + records, total, err := s.List(ctx, &store.RobotListOptions{ + Keywords: "Robot", + }) + require.NoError(t, err) + // Should find at least our 4 test robots + assert.GreaterOrEqual(t, len(records), 4) + assert.GreaterOrEqual(t, total, 4) + }) + + t.Run("filters_by_team_id", func(t *testing.T) { + records, total, err := s.List(ctx, &store.RobotListOptions{ + TeamID: "team_list_001", + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + assert.Equal(t, 2, total) + for _, r := range records { + assert.Equal(t, "team_list_001", r.TeamID) + } + }) + + t.Run("filters_by_robot_status", func(t *testing.T) { + records, _, err := s.List(ctx, &store.RobotListOptions{ + Status: "working", + }) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(records), 1) + for _, r := range records { + assert.Equal(t, "working", r.RobotStatus) + } + }) + + t.Run("filters_by_keywords", func(t *testing.T) { + records, _, err := s.List(ctx, &store.RobotListOptions{ + Keywords: "Alpha", + }) + require.NoError(t, err) + assert.Equal(t, 1, len(records)) + assert.Contains(t, records[0].DisplayName, "Alpha") + }) + + t.Run("respects_pagination", func(t *testing.T) { + records, total, err := s.List(ctx, &store.RobotListOptions{ + Page: 1, + PageSize: 2, + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + assert.GreaterOrEqual(t, total, 4) // total count should be full count + }) + + t.Run("respects_limit", func(t *testing.T) { + records, _, err := s.List(ctx, &store.RobotListOptions{ + Limit: 2, + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + }) + + t.Run("combines_multiple_filters", func(t *testing.T) { + records, total, err := s.List(ctx, &store.RobotListOptions{ + TeamID: "team_list_001", + Status: "idle", + }) + require.NoError(t, err) + assert.Equal(t, 1, len(records)) + assert.Equal(t, 1, total) + assert.Equal(t, "team_list_001", records[0].TeamID) + assert.Equal(t, "idle", records[0].RobotStatus) + }) +} + +// TestRobotStoreDelete tests deleting robot records +func TestRobotStoreDelete(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + t.Run("deletes_existing_record", func(t *testing.T) { + // Create a record + record := &store.RobotRecord{ + MemberID: "robot_test_delete_001", + TeamID: "team_delete_001", + DisplayName: "Robot to Delete", + Status: "active", + RobotStatus: "idle", + } + err := s.Save(ctx, record) + require.NoError(t, err) + + // Verify it exists + saved, err := s.Get(ctx, "robot_test_delete_001") + require.NoError(t, err) + require.NotNil(t, saved) + + // Delete it + err = s.Delete(ctx, "robot_test_delete_001") + require.NoError(t, err) + + // Verify it's gone + saved, err = s.Get(ctx, "robot_test_delete_001") + require.NoError(t, err) + assert.Nil(t, saved) + }) + + t.Run("no_error_for_non_existent_record", func(t *testing.T) { + err := s.Delete(ctx, "robot_non_existent") + assert.NoError(t, err) + }) +} + +// TestRobotStoreUpdateConfig tests updating robot config +func TestRobotStoreUpdateConfig(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + // Create a base record + record := &store.RobotRecord{ + MemberID: "robot_test_config_001", + TeamID: "team_config_001", + DisplayName: "Config Test Robot", + Status: "active", + RobotStatus: "idle", + RobotConfig: map[string]interface{}{ + "clock_mode": "off", + }, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + t.Run("updates_config_only", func(t *testing.T) { + newConfig := map[string]interface{}{ + "clock_mode": "on", + "max_concurrent": 5, + "timeout_seconds": 600, + } + err := s.UpdateConfig(ctx, "robot_test_config_001", newConfig) + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_config_001") + require.NoError(t, err) + require.NotNil(t, saved) + assert.NotNil(t, saved.RobotConfig) + + // Display name should be unchanged + assert.Equal(t, "Config Test Robot", saved.DisplayName) + }) +} + +// TestRobotStoreUpdateStatus tests updating robot status +func TestRobotStoreUpdateStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + // Create a base record + record := &store.RobotRecord{ + MemberID: "robot_test_status_001", + TeamID: "team_status_001", + DisplayName: "Status Test Robot", + Status: "active", + RobotStatus: "idle", + } + err := s.Save(ctx, record) + require.NoError(t, err) + + t.Run("updates_robot_status", func(t *testing.T) { + err := s.UpdateStatus(ctx, "robot_test_status_001", "working") + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_status_001") + require.NoError(t, err) + require.NotNil(t, saved) + assert.Equal(t, "working", saved.RobotStatus) + // Display name should be unchanged + assert.Equal(t, "Status Test Robot", saved.DisplayName) + }) + + t.Run("updates_to_paused", func(t *testing.T) { + err := s.UpdateStatus(ctx, "robot_test_status_001", "paused") + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_status_001") + require.NoError(t, err) + assert.Equal(t, "paused", saved.RobotStatus) + }) + + t.Run("updates_to_error", func(t *testing.T) { + err := s.UpdateStatus(ctx, "robot_test_status_001", "error") + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_status_001") + require.NoError(t, err) + assert.Equal(t, "error", saved.RobotStatus) + }) +} + +// TestRobotRecordConversion tests conversion between RobotRecord and Robot types +func TestRobotRecordConversion(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + t.Run("converts_record_to_robot", func(t *testing.T) { + now := time.Now() + record := &store.RobotRecord{ + MemberID: "robot_convert_001", + TeamID: "team_convert_001", + DisplayName: "Conversion Test Robot", + Bio: "Test description", + SystemPrompt: "You are helpful", + Status: "active", + RobotStatus: "idle", + AutonomousMode: true, + RobotEmail: "convert@test.com", + JoinedAt: &now, + RobotConfig: map[string]interface{}{ + "clock_mode": "on", + }, + } + + robot, err := record.ToRobot() + require.NoError(t, err) + require.NotNil(t, robot) + + assert.Equal(t, "robot_convert_001", robot.MemberID) + assert.Equal(t, "team_convert_001", robot.TeamID) + assert.Equal(t, "Conversion Test Robot", robot.DisplayName) + assert.Equal(t, "Test description", robot.Bio) + assert.Equal(t, "You are helpful", robot.SystemPrompt) + assert.True(t, robot.AutonomousMode) + assert.Equal(t, "convert@test.com", robot.RobotEmail) + }) + + t.Run("converts_robot_to_record", func(t *testing.T) { + robot := &store.RobotRecord{ + MemberID: "robot_from_001", + TeamID: "team_from_001", + DisplayName: "From Robot Test", + Bio: "From robot description", + SystemPrompt: "System prompt", + RobotStatus: "working", + AutonomousMode: false, + RobotEmail: "from@test.com", + } + + // ToRobot and verify + converted, err := robot.ToRobot() + require.NoError(t, err) + + assert.Equal(t, "robot_from_001", converted.MemberID) + assert.Equal(t, "team_from_001", converted.TeamID) + assert.Equal(t, "From Robot Test", converted.DisplayName) + }) +} + +// Helper functions + +func cleanupTestRobots(t *testing.T) { + mod := model.Select("__yao.member") + if mod == nil { + return + } + + // Delete all test robot records + _, err := mod.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", OP: "like", Value: "robot_test_%"}, + {Column: "member_type", Value: "robot"}, + }, + }) + if err != nil { + t.Logf("Warning: failed to cleanup test robots: %v", err) + } +} + +func setupTestRobot(t *testing.T, s *store.RobotStore, ctx context.Context) { + now := time.Now() + record := &store.RobotRecord{ + MemberID: "robot_test_get_001", + TeamID: "team_test_get", + DisplayName: "Test Robot Get", + Bio: "Test robot description", + SystemPrompt: "You are a test assistant", + Status: "active", + RobotStatus: "idle", + AutonomousMode: false, + RobotEmail: "test@robot.com", + JoinedAt: &now, + } + + err := s.Save(ctx, record) + require.NoError(t, err) +} + +func setupTestRobotsForList(t *testing.T, s *store.RobotStore, ctx context.Context) { + now := time.Now() + + records := []*store.RobotRecord{ + { + MemberID: "robot_test_list_001", + TeamID: "team_list_001", + DisplayName: "Robot Alpha", + Status: "active", + RobotStatus: "idle", + JoinedAt: &now, + }, + { + MemberID: "robot_test_list_002", + TeamID: "team_list_001", + DisplayName: "Robot Beta", + Status: "active", + RobotStatus: "working", + JoinedAt: &now, + }, + { + MemberID: "robot_test_list_003", + TeamID: "team_list_002", + DisplayName: "Robot Gamma", + Status: "active", + RobotStatus: "idle", + JoinedAt: &now, + }, + { + MemberID: "robot_test_list_004", + TeamID: "team_list_002", + DisplayName: "Robot Delta", + Status: "inactive", + RobotStatus: "paused", + JoinedAt: &now, + }, + } + + for _, record := range records { + err := s.Save(ctx, record) + require.NoError(t, err) + } +} diff --git a/agent/robot/types/robot.go b/agent/robot/types/robot.go index d84b94bb..a03278fd 100644 --- a/agent/robot/types/robot.go +++ b/agent/robot/types/robot.go @@ -17,6 +17,7 @@ type Robot struct { MemberID string `json:"member_id"` TeamID string `json:"team_id"` DisplayName string `json:"display_name"` + Bio string `json:"bio"` // Robot's description (from __yao.member.bio) SystemPrompt string `json:"system_prompt"` Status RobotStatus `json:"robot_status"` AutonomousMode bool `json:"autonomous_mode"` @@ -381,6 +382,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) { MemberID: memberID, TeamID: teamID, DisplayName: getString(m, "display_name"), + Bio: getString(m, "bio"), SystemPrompt: getString(m, "system_prompt"), AutonomousMode: getBool(m, "autonomous_mode"), RobotEmail: getString(m, "robot_email"), diff --git a/agent/robot/utils/convert.go b/agent/robot/utils/convert.go index aec43c25..14ed8de0 100644 --- a/agent/robot/utils/convert.go +++ b/agent/robot/utils/convert.go @@ -3,8 +3,384 @@ package utils import ( "encoding/json" "fmt" + "time" ) +// ==================== To Functions ==================== +// Convert any value to specified type (safe, returns zero value on failure) + +// ToString converts any value to string +func ToString(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case []byte: + return string(val) + case int: + return fmt.Sprintf("%d", val) + case int8: + return fmt.Sprintf("%d", val) + case int16: + return fmt.Sprintf("%d", val) + case int32: + return fmt.Sprintf("%d", val) + case int64: + return fmt.Sprintf("%d", val) + case uint: + return fmt.Sprintf("%d", val) + case uint8: + return fmt.Sprintf("%d", val) + case uint16: + return fmt.Sprintf("%d", val) + case uint32: + return fmt.Sprintf("%d", val) + case uint64: + return fmt.Sprintf("%d", val) + case float32: + return fmt.Sprintf("%g", val) + case float64: + return fmt.Sprintf("%g", val) + case bool: + if val { + return "true" + } + return "false" + default: + if str, err := json.Marshal(v); err == nil { + return string(str) + } + return fmt.Sprintf("%v", v) + } +} + +// ToBool converts any value to bool +func ToBool(v interface{}) bool { + if v == nil { + return false + } + switch b := v.(type) { + case bool: + return b + case int: + return b != 0 + case int8: + return b != 0 + case int16: + return b != 0 + case int32: + return b != 0 + case int64: + return b != 0 + case uint: + return b != 0 + case uint8: + return b != 0 + case uint16: + return b != 0 + case uint32: + return b != 0 + case uint64: + return b != 0 + case float32: + return b != 0 + case float64: + return b != 0 + case string: + return b == "true" || b == "1" || b == "yes" || b == "on" + } + return false +} + +// ToInt converts any value to int +func ToInt(v interface{}) int { + if v == nil { + return 0 + } + switch n := v.(type) { + case int: + return n + case int8: + return int(n) + case int16: + return int(n) + case int32: + return int(n) + case int64: + return int(n) + case uint: + return int(n) + case uint8: + return int(n) + case uint16: + return int(n) + case uint32: + return int(n) + case uint64: + return int(n) + case float32: + return int(n) + case float64: + return int(n) + case string: + var i int + fmt.Sscanf(n, "%d", &i) + return i + case bool: + if n { + return 1 + } + return 0 + } + return 0 +} + +// ToInt64 converts any value to int64 +func ToInt64(v interface{}) int64 { + if v == nil { + return 0 + } + switch n := v.(type) { + case int64: + return n + case int: + return int64(n) + case int8: + return int64(n) + case int16: + return int64(n) + case int32: + return int64(n) + case uint: + return int64(n) + case uint8: + return int64(n) + case uint16: + return int64(n) + case uint32: + return int64(n) + case uint64: + return int64(n) + case float32: + return int64(n) + case float64: + return int64(n) + case string: + var i int64 + fmt.Sscanf(n, "%d", &i) + return i + case bool: + if n { + return 1 + } + return 0 + } + return 0 +} + +// ToFloat64 converts any value to float64 +func ToFloat64(v interface{}) float64 { + if v == nil { + return 0 + } + switch f := v.(type) { + case float64: + return f + case float32: + return float64(f) + case int: + return float64(f) + case int8: + return float64(f) + case int16: + return float64(f) + case int32: + return float64(f) + case int64: + return float64(f) + case uint: + return float64(f) + case uint8: + return float64(f) + case uint16: + return float64(f) + case uint32: + return float64(f) + case uint64: + return float64(f) + case string: + var result float64 + fmt.Sscanf(f, "%f", &result) + return result + case bool: + if f { + return 1 + } + return 0 + } + return 0 +} + +// ToTimestamp converts any value to *time.Time +// Handles: time.Time, *time.Time, string (various formats), int64/float64 (unix timestamp) +func ToTimestamp(v interface{}) *time.Time { + if v == nil { + return nil + } + switch t := v.(type) { + case time.Time: + return &t + case *time.Time: + return t + case string: + if t == "" { + return nil + } + // Try common time formats + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02", + } + for _, format := range formats { + if parsed, err := time.Parse(format, t); err == nil { + return &parsed + } + } + case int64: + // Unix timestamp (seconds) + parsed := time.Unix(t, 0) + return &parsed + case int: + parsed := time.Unix(int64(t), 0) + return &parsed + case float64: + // Unix timestamp (seconds as float) + parsed := time.Unix(int64(t), 0) + return &parsed + } + return nil +} + +// ToJSONValue parses JSON from string/[]byte or returns already-parsed value +func ToJSONValue(v interface{}) interface{} { + if v == nil { + return nil + } + switch data := v.(type) { + case string: + if data == "" { + return nil + } + var result interface{} + if err := json.Unmarshal([]byte(data), &result); err != nil { + return nil + } + return result + case []byte: + if len(data) == 0 { + return nil + } + var result interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + return result + case map[string]interface{}, []interface{}: + // Already parsed + return data + default: + return v + } +} + +// ==================== Get Functions ==================== +// Safely get typed value from map[string]interface{} + +// GetString safely gets a string value from map +func GetString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if v, ok := m[key]; ok { + return ToString(v) + } + return "" +} + +// GetBool safely gets a bool value from map +func GetBool(m map[string]interface{}, key string) bool { + if m == nil { + return false + } + if v, ok := m[key]; ok { + return ToBool(v) + } + return false +} + +// GetInt safely gets an int value from map +func GetInt(m map[string]interface{}, key string) int { + if m == nil { + return 0 + } + if v, ok := m[key]; ok { + return ToInt(v) + } + return 0 +} + +// GetInt64 safely gets an int64 value from map +func GetInt64(m map[string]interface{}, key string) int64 { + if m == nil { + return 0 + } + if v, ok := m[key]; ok { + return ToInt64(v) + } + return 0 +} + +// GetFloat64 safely gets a float64 value from map +func GetFloat64(m map[string]interface{}, key string) float64 { + if m == nil { + return 0 + } + if v, ok := m[key]; ok { + return ToFloat64(v) + } + return 0 +} + +// GetTimestamp safely gets a *time.Time value from map +func GetTimestamp(m map[string]interface{}, key string) *time.Time { + if m == nil { + return nil + } + if v, ok := m[key]; ok { + return ToTimestamp(v) + } + return nil +} + +// GetJSONValue safely gets a parsed JSON value from map +func GetJSONValue(m map[string]interface{}, key string) interface{} { + if m == nil { + return nil + } + if v, ok := m[key]; ok { + return ToJSONValue(v) + } + return nil +} + +// ==================== JSON/Map Conversion ==================== + // ToJSON converts any value to JSON string func ToJSON(v interface{}) (string, error) { data, err := json.Marshal(v) @@ -14,7 +390,7 @@ func ToJSON(v interface{}) (string, error) { return string(data), nil } -// FromJSON parses JSON string to target +// FromJSON parses JSON string to target struct func FromJSON(jsonStr string, target interface{}) error { return json.Unmarshal([]byte(jsonStr), target) } @@ -25,12 +401,10 @@ func ToMap(v interface{}) (map[string]interface{}, error) { if err != nil { return nil, err } - var result map[string]interface{} if err := json.Unmarshal(data, &result); err != nil { return nil, err } - return result, nil } @@ -43,29 +417,7 @@ func FromMap(m map[string]interface{}, target interface{}) error { return json.Unmarshal(data, target) } -// ToString converts any value to string -func ToString(v interface{}) string { - switch val := v.(type) { - case string: - return val - case []byte: - return string(val) - case int, int8, int16, int32, int64: - return fmt.Sprintf("%d", val) - case uint, uint8, uint16, uint32, uint64: - return fmt.Sprintf("%d", val) - case float32, float64: - return fmt.Sprintf("%f", val) - case bool: - return fmt.Sprintf("%t", val) - default: - // Fallback to JSON - if str, err := ToJSON(v); err == nil { - return str - } - return fmt.Sprintf("%v", v) - } -} +// ==================== Map Utilities ==================== // MergeMap merges source map into target map (shallow copy) func MergeMap(target, source map[string]interface{}) map[string]interface{} { @@ -89,58 +441,3 @@ func CloneMap(m map[string]interface{}) map[string]interface{} { } return result } - -// GetString safely gets a string value from map -func GetString(m map[string]interface{}, key string) string { - if m == nil { - return "" - } - if v, ok := m[key]; ok && v != nil { - return ToString(v) - } - return "" -} - -// GetBool safely gets a bool value from map -func GetBool(m map[string]interface{}, key string) bool { - if m == nil { - return false - } - if v, ok := m[key]; ok && v != nil { - switch b := v.(type) { - case bool: - return b - case int: - return b != 0 - case int64: - return b != 0 - case float64: - return b != 0 - case string: - return b == "true" || b == "1" - } - } - return false -} - -// GetInt safely gets an int value from map -func GetInt(m map[string]interface{}, key string) int { - if m == nil { - return 0 - } - if v, ok := m[key]; ok && v != nil { - switch n := v.(type) { - case int: - return n - case int64: - return int(n) - case float64: - return int(n) - case string: - var i int - fmt.Sscanf(n, "%d", &i) - return i - } - } - return 0 -} diff --git a/agent/robot/utils/convert_test.go b/agent/robot/utils/convert_test.go new file mode 100644 index 00000000..691740d0 --- /dev/null +++ b/agent/robot/utils/convert_test.go @@ -0,0 +1,567 @@ +package utils_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/agent/robot/utils" +) + +// ==================== To Tests ==================== + +func TestToBool(t *testing.T) { + t.Run("from_bool", func(t *testing.T) { + assert.True(t, utils.ToBool(true)) + assert.False(t, utils.ToBool(false)) + }) + + t.Run("from_int", func(t *testing.T) { + assert.True(t, utils.ToBool(1)) + assert.True(t, utils.ToBool(42)) + assert.False(t, utils.ToBool(0)) + }) + + t.Run("from_int64", func(t *testing.T) { + assert.True(t, utils.ToBool(int64(1))) + assert.False(t, utils.ToBool(int64(0))) + }) + + t.Run("from_float64", func(t *testing.T) { + assert.True(t, utils.ToBool(1.0)) + assert.True(t, utils.ToBool(0.1)) + assert.False(t, utils.ToBool(0.0)) + }) + + t.Run("from_string", func(t *testing.T) { + assert.True(t, utils.ToBool("true")) + assert.True(t, utils.ToBool("1")) + assert.True(t, utils.ToBool("yes")) + assert.True(t, utils.ToBool("on")) + assert.False(t, utils.ToBool("false")) + assert.False(t, utils.ToBool("0")) + assert.False(t, utils.ToBool("")) + }) + + t.Run("from_nil", func(t *testing.T) { + assert.False(t, utils.ToBool(nil)) + }) + + t.Run("from_unsupported_type", func(t *testing.T) { + assert.False(t, utils.ToBool([]int{1, 2, 3})) + }) +} + +func TestToInt(t *testing.T) { + t.Run("from_int", func(t *testing.T) { + assert.Equal(t, 42, utils.ToInt(42)) + assert.Equal(t, -10, utils.ToInt(-10)) + }) + + t.Run("from_int64", func(t *testing.T) { + assert.Equal(t, 100, utils.ToInt(int64(100))) + }) + + t.Run("from_float64", func(t *testing.T) { + assert.Equal(t, 42, utils.ToInt(42.9)) // truncates + assert.Equal(t, -5, utils.ToInt(-5.7)) + }) + + t.Run("from_string", func(t *testing.T) { + assert.Equal(t, 123, utils.ToInt("123")) + assert.Equal(t, -456, utils.ToInt("-456")) + assert.Equal(t, 0, utils.ToInt("invalid")) + }) + + t.Run("from_bool", func(t *testing.T) { + assert.Equal(t, 1, utils.ToInt(true)) + assert.Equal(t, 0, utils.ToInt(false)) + }) + + t.Run("from_nil", func(t *testing.T) { + assert.Equal(t, 0, utils.ToInt(nil)) + }) +} + +func TestToInt64(t *testing.T) { + t.Run("from_int64", func(t *testing.T) { + assert.Equal(t, int64(9223372036854775807), utils.ToInt64(int64(9223372036854775807))) + }) + + t.Run("from_int", func(t *testing.T) { + assert.Equal(t, int64(42), utils.ToInt64(42)) + }) + + t.Run("from_float64", func(t *testing.T) { + assert.Equal(t, int64(42), utils.ToInt64(42.9)) + }) + + t.Run("from_string", func(t *testing.T) { + assert.Equal(t, int64(123456789), utils.ToInt64("123456789")) + }) + + t.Run("from_nil", func(t *testing.T) { + assert.Equal(t, int64(0), utils.ToInt64(nil)) + }) +} + +func TestToFloat64(t *testing.T) { + t.Run("from_float64", func(t *testing.T) { + assert.Equal(t, 3.14159, utils.ToFloat64(3.14159)) + }) + + t.Run("from_float32", func(t *testing.T) { + assert.InDelta(t, 3.14, utils.ToFloat64(float32(3.14)), 0.001) + }) + + t.Run("from_int", func(t *testing.T) { + assert.Equal(t, 42.0, utils.ToFloat64(42)) + }) + + t.Run("from_int64", func(t *testing.T) { + assert.Equal(t, 100.0, utils.ToFloat64(int64(100))) + }) + + t.Run("from_string", func(t *testing.T) { + assert.InDelta(t, 3.14, utils.ToFloat64("3.14"), 0.001) + assert.Equal(t, 0.0, utils.ToFloat64("invalid")) + }) + + t.Run("from_bool", func(t *testing.T) { + assert.Equal(t, 1.0, utils.ToFloat64(true)) + assert.Equal(t, 0.0, utils.ToFloat64(false)) + }) + + t.Run("from_nil", func(t *testing.T) { + assert.Equal(t, 0.0, utils.ToFloat64(nil)) + }) +} + +func TestToTimestamp(t *testing.T) { + t.Run("from_time_Time", func(t *testing.T) { + now := time.Now() + result := utils.ToTimestamp(now) + assert.NotNil(t, result) + assert.Equal(t, now.Unix(), result.Unix()) + }) + + t.Run("from_time_Time_pointer", func(t *testing.T) { + now := time.Now() + result := utils.ToTimestamp(&now) + assert.NotNil(t, result) + assert.Equal(t, now.Unix(), result.Unix()) + }) + + t.Run("from_RFC3339_string", func(t *testing.T) { + result := utils.ToTimestamp("2024-01-15T14:30:00Z") + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + assert.Equal(t, time.January, result.Month()) + assert.Equal(t, 15, result.Day()) + assert.Equal(t, 14, result.Hour()) + assert.Equal(t, 30, result.Minute()) + }) + + t.Run("from_datetime_string", func(t *testing.T) { + result := utils.ToTimestamp("2024-01-15 14:30:00") + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + }) + + t.Run("from_date_string", func(t *testing.T) { + result := utils.ToTimestamp("2024-01-15") + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + assert.Equal(t, 15, result.Day()) + }) + + t.Run("from_unix_timestamp_int64", func(t *testing.T) { + // 2024-01-15 00:00:00 UTC + result := utils.ToTimestamp(int64(1705276800)) + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + }) + + t.Run("from_unix_timestamp_float64", func(t *testing.T) { + result := utils.ToTimestamp(float64(1705276800)) + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + }) + + t.Run("from_empty_string", func(t *testing.T) { + result := utils.ToTimestamp("") + assert.Nil(t, result) + }) + + t.Run("from_invalid_string", func(t *testing.T) { + result := utils.ToTimestamp("not a date") + assert.Nil(t, result) + }) + + t.Run("from_nil", func(t *testing.T) { + result := utils.ToTimestamp(nil) + assert.Nil(t, result) + }) +} + +func TestToJSONValue(t *testing.T) { + t.Run("from_json_string_object", func(t *testing.T) { + result := utils.ToJSONValue(`{"name":"test","age":30}`) + assert.NotNil(t, result) + m, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "test", m["name"]) + assert.Equal(t, float64(30), m["age"]) + }) + + t.Run("from_json_string_array", func(t *testing.T) { + result := utils.ToJSONValue(`["a","b","c"]`) + assert.NotNil(t, result) + arr, ok := result.([]interface{}) + assert.True(t, ok) + assert.Len(t, arr, 3) + assert.Equal(t, "a", arr[0]) + }) + + t.Run("from_bytes", func(t *testing.T) { + result := utils.ToJSONValue([]byte(`{"key":"value"}`)) + assert.NotNil(t, result) + m, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "value", m["key"]) + }) + + t.Run("from_already_parsed_map", func(t *testing.T) { + input := map[string]interface{}{"foo": "bar"} + result := utils.ToJSONValue(input) + assert.Equal(t, input, result) + }) + + t.Run("from_already_parsed_array", func(t *testing.T) { + input := []interface{}{"a", "b"} + result := utils.ToJSONValue(input) + assert.Equal(t, input, result) + }) + + t.Run("from_empty_string", func(t *testing.T) { + result := utils.ToJSONValue("") + assert.Nil(t, result) + }) + + t.Run("from_empty_bytes", func(t *testing.T) { + result := utils.ToJSONValue([]byte{}) + assert.Nil(t, result) + }) + + t.Run("from_invalid_json", func(t *testing.T) { + result := utils.ToJSONValue("not json") + assert.Nil(t, result) + }) + + t.Run("from_nil", func(t *testing.T) { + result := utils.ToJSONValue(nil) + assert.Nil(t, result) + }) + + t.Run("from_other_type_passthrough", func(t *testing.T) { + // Non-string, non-[]byte types are passed through + result := utils.ToJSONValue(42) + assert.Equal(t, 42, result) + }) +} + +// ==================== Get Tests ==================== + +func TestGetString(t *testing.T) { + m := map[string]interface{}{ + "name": "test", + "number": 42, + "bool": true, + "nil": nil, + } + + t.Run("existing_string_key", func(t *testing.T) { + assert.Equal(t, "test", utils.GetString(m, "name")) + }) + + t.Run("converts_number_to_string", func(t *testing.T) { + assert.Equal(t, "42", utils.GetString(m, "number")) + }) + + t.Run("converts_bool_to_string", func(t *testing.T) { + assert.Equal(t, "true", utils.GetString(m, "bool")) + }) + + t.Run("non_existent_key", func(t *testing.T) { + assert.Equal(t, "", utils.GetString(m, "missing")) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.Equal(t, "", utils.GetString(nil, "key")) + }) + + t.Run("nil_value", func(t *testing.T) { + assert.Equal(t, "", utils.GetString(m, "nil")) + }) +} + +func TestGetBool(t *testing.T) { + m := map[string]interface{}{ + "bool_true": true, + "bool_false": false, + "int_one": 1, + "int_zero": 0, + "string_true": "true", + } + + t.Run("bool_true", func(t *testing.T) { + assert.True(t, utils.GetBool(m, "bool_true")) + }) + + t.Run("bool_false", func(t *testing.T) { + assert.False(t, utils.GetBool(m, "bool_false")) + }) + + t.Run("int_one", func(t *testing.T) { + assert.True(t, utils.GetBool(m, "int_one")) + }) + + t.Run("int_zero", func(t *testing.T) { + assert.False(t, utils.GetBool(m, "int_zero")) + }) + + t.Run("string_true", func(t *testing.T) { + assert.True(t, utils.GetBool(m, "string_true")) + }) + + t.Run("non_existent_key", func(t *testing.T) { + assert.False(t, utils.GetBool(m, "missing")) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.False(t, utils.GetBool(nil, "key")) + }) +} + +func TestGetInt(t *testing.T) { + m := map[string]interface{}{ + "int": 42, + "int64": int64(100), + "float64": 3.14, + "string": "123", + } + + t.Run("int", func(t *testing.T) { + assert.Equal(t, 42, utils.GetInt(m, "int")) + }) + + t.Run("int64", func(t *testing.T) { + assert.Equal(t, 100, utils.GetInt(m, "int64")) + }) + + t.Run("float64", func(t *testing.T) { + assert.Equal(t, 3, utils.GetInt(m, "float64")) + }) + + t.Run("string", func(t *testing.T) { + assert.Equal(t, 123, utils.GetInt(m, "string")) + }) + + t.Run("non_existent_key", func(t *testing.T) { + assert.Equal(t, 0, utils.GetInt(m, "missing")) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.Equal(t, 0, utils.GetInt(nil, "key")) + }) +} + +func TestGetInt64(t *testing.T) { + m := map[string]interface{}{ + "int64": int64(9223372036854775807), + "int": 42, + "string": "123456789", + } + + t.Run("int64", func(t *testing.T) { + assert.Equal(t, int64(9223372036854775807), utils.GetInt64(m, "int64")) + }) + + t.Run("int", func(t *testing.T) { + assert.Equal(t, int64(42), utils.GetInt64(m, "int")) + }) + + t.Run("string", func(t *testing.T) { + assert.Equal(t, int64(123456789), utils.GetInt64(m, "string")) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.Equal(t, int64(0), utils.GetInt64(nil, "key")) + }) +} + +func TestGetFloat64(t *testing.T) { + m := map[string]interface{}{ + "float64": 3.14159, + "int": 42, + "string": "2.718", + } + + t.Run("float64", func(t *testing.T) { + assert.Equal(t, 3.14159, utils.GetFloat64(m, "float64")) + }) + + t.Run("int", func(t *testing.T) { + assert.Equal(t, 42.0, utils.GetFloat64(m, "int")) + }) + + t.Run("string", func(t *testing.T) { + assert.InDelta(t, 2.718, utils.GetFloat64(m, "string"), 0.001) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.Equal(t, 0.0, utils.GetFloat64(nil, "key")) + }) +} + +func TestGetTimestamp(t *testing.T) { + now := time.Now() + m := map[string]interface{}{ + "time": now, + "time_ptr": &now, + "rfc3339": "2024-01-15T14:30:00Z", + "unix": int64(1705276800), + "empty": "", + "nil_value": nil, + } + + t.Run("time_value", func(t *testing.T) { + result := utils.GetTimestamp(m, "time") + assert.NotNil(t, result) + assert.Equal(t, now.Unix(), result.Unix()) + }) + + t.Run("time_ptr", func(t *testing.T) { + result := utils.GetTimestamp(m, "time_ptr") + assert.NotNil(t, result) + }) + + t.Run("rfc3339_string", func(t *testing.T) { + result := utils.GetTimestamp(m, "rfc3339") + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + }) + + t.Run("unix_timestamp", func(t *testing.T) { + result := utils.GetTimestamp(m, "unix") + assert.NotNil(t, result) + }) + + t.Run("empty_string", func(t *testing.T) { + result := utils.GetTimestamp(m, "empty") + assert.Nil(t, result) + }) + + t.Run("nil_value", func(t *testing.T) { + result := utils.GetTimestamp(m, "nil_value") + assert.Nil(t, result) + }) + + t.Run("non_existent_key", func(t *testing.T) { + result := utils.GetTimestamp(m, "missing") + assert.Nil(t, result) + }) + + t.Run("nil_map", func(t *testing.T) { + result := utils.GetTimestamp(nil, "key") + assert.Nil(t, result) + }) +} + +func TestGetJSONValue(t *testing.T) { + m := map[string]interface{}{ + "json_string": `{"nested":"value"}`, + "json_array": `[1,2,3]`, + "parsed_map": map[string]interface{}{"foo": "bar"}, + "empty": "", + "invalid": "not json", + } + + t.Run("json_string", func(t *testing.T) { + result := utils.GetJSONValue(m, "json_string") + assert.NotNil(t, result) + nested, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "value", nested["nested"]) + }) + + t.Run("json_array", func(t *testing.T) { + result := utils.GetJSONValue(m, "json_array") + assert.NotNil(t, result) + arr, ok := result.([]interface{}) + assert.True(t, ok) + assert.Len(t, arr, 3) + }) + + t.Run("parsed_map", func(t *testing.T) { + result := utils.GetJSONValue(m, "parsed_map") + assert.NotNil(t, result) + parsed, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "bar", parsed["foo"]) + }) + + t.Run("empty_string", func(t *testing.T) { + result := utils.GetJSONValue(m, "empty") + assert.Nil(t, result) + }) + + t.Run("invalid_json", func(t *testing.T) { + result := utils.GetJSONValue(m, "invalid") + assert.Nil(t, result) + }) + + t.Run("nil_map", func(t *testing.T) { + result := utils.GetJSONValue(nil, "key") + assert.Nil(t, result) + }) +} + +// ==================== ToString Extended Tests ==================== + +func TestToStringExtended(t *testing.T) { + t.Run("from_nil", func(t *testing.T) { + assert.Equal(t, "", utils.ToString(nil)) + }) + + t.Run("from_bytes", func(t *testing.T) { + assert.Equal(t, "hello", utils.ToString([]byte("hello"))) + }) + + t.Run("from_int_types", func(t *testing.T) { + assert.Equal(t, "8", utils.ToString(int8(8))) + assert.Equal(t, "16", utils.ToString(int16(16))) + assert.Equal(t, "32", utils.ToString(int32(32))) + assert.Equal(t, "64", utils.ToString(int64(64))) + }) + + t.Run("from_uint_types", func(t *testing.T) { + assert.Equal(t, "8", utils.ToString(uint8(8))) + assert.Equal(t, "16", utils.ToString(uint16(16))) + assert.Equal(t, "32", utils.ToString(uint32(32))) + assert.Equal(t, "64", utils.ToString(uint64(64))) + }) + + t.Run("from_float_formats_nicely", func(t *testing.T) { + assert.Equal(t, "3.14", utils.ToString(3.14)) + assert.Equal(t, "1000", utils.ToString(1000.0)) // no trailing zeros + }) + + t.Run("from_struct_to_json", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + } + result := utils.ToString(TestStruct{Name: "test"}) + assert.Contains(t, result, "test") + }) +} diff --git a/openapi/agent/robot/TODO.md b/openapi/agent/robot/TODO.md index 33fcb334..7db645ed 100644 --- a/openapi/agent/robot/TODO.md +++ b/openapi/agent/robot/TODO.md @@ -35,100 +35,117 @@ --- -## 🟢 Phase 1: Core CRUD ⬜ [Low Risk] +## 🟢 Phase 1: Core CRUD 🟡 [Low Risk] **Goal:** Basic robot management endpoints **Risk:** 🟢 Low - All new code, no changes to existing logic -### 1.1 Backend Prerequisites ⬜ +### 1.1 Backend Prerequisites ✅ #### Types & Cache -- [ ] Add `Bio` field to `types.Robot` struct in `yao/agent/robot/types/robot.go` -- [ ] Add `bio` to `memberFields` in `yao/agent/robot/cache/load.go` +- [x] Add `Bio` field to `types.Robot` struct in `yao/agent/robot/types/robot.go` +- [x] Add `bio` to `memberFields` in `yao/agent/robot/cache/load.go` #### Store Layer (Core CRUD - implement first) -- [ ] Create `store/robot.go` with `RobotStore` struct -- [ ] Implement `RobotStore.Save()` - create/update robot member -- [ ] Implement `RobotStore.Get()` - get by member_id -- [ ] Implement `RobotStore.List()` - list with filters -- [ ] Implement `RobotStore.Delete()` - delete robot member -- [ ] Implement `RobotStore.UpdateConfig()` - update config only +- [x] Create `store/robot.go` with `RobotStore` struct +- [x] Implement `RobotStore.Save()` - create/update robot member +- [x] Implement `RobotStore.Get()` - get by member_id +- [x] Implement `RobotStore.List()` - list with filters +- [x] Implement `RobotStore.Delete()` - delete robot member +- [x] Implement `RobotStore.UpdateConfig()` - update config only +- [x] Implement `RobotStore.UpdateStatus()` - update status only +- [x] Add Yao permission fields support (`__yao_created_by`, `__yao_team_id`, etc.) +- [x] Add tests: `store/robot_test.go` #### API Layer (Thin wrappers calling store) -- [ ] Implement `api.Create()` - call `store.RobotStore.Save()` + cache refresh -- [ ] Implement `api.Update()` - call `store.RobotStore.UpdateConfig()` + cache refresh -- [ ] Implement `api.Remove()` - call `store.RobotStore.Delete()` + cache invalidate +- [x] Implement `api.CreateRobot()` - call `store.RobotStore.Save()` + cache refresh +- [x] Implement `api.UpdateRobot()` - partial update + cache refresh +- [x] Implement `api.RemoveRobot()` - call `store.RobotStore.Delete()` + cache invalidate +- [x] Implement `api.GetRobotResponse()` - get robot as API response +- [x] Add `AuthScope` for Yao permission fields +- [x] Add request/response types in `api/types.go` +- [x] Add tests: `api/robot_test.go` -### 1.2 Setup ⬜ +#### Utils Layer +- [x] Create `utils/convert.go` with unified type conversion functions +- [x] Implement `To` functions (ToBool, ToInt, ToFloat64, ToTimestamp, ToJSONValue) +- [x] Implement `Get` functions for map value extraction +- [x] Add tests: `utils/convert_test.go` + +### 1.2 OpenAPI Setup ⬜ (Next Step) - [ ] Create `openapi/agent/robot/` directory (sub-package under agent) - [ ] Create `robot.go` - route registration with `Attach()` function - [ ] Register routes in `openapi/agent/agent.go` via `robot.Attach(group.Group("/robots"), oauth)` - [ ] Add OAuth guard middleware -### 1.3 Types ⬜ +### 1.3 OpenAPI Types ⬜ -- [ ] `types.go` - request/response types +> Note: Core types already exist in `agent/robot/api/types.go`. OpenAPI layer needs HTTP-specific types. + +- [ ] `types.go` - HTTP request/response types - [ ] `RobotResponse` struct (with field mapping: `name` ← `member_id`, `description` ← `bio`) - [ ] `ConfigResponse` struct (and sub-types) - [ ] `ListRobotsResponse` struct - - [ ] `CreateRobotRequest` struct - - [ ] `UpdateRobotRequest` struct - - [ ] `NewRobotResponse()` - conversion function + - [ ] `CreateRobotRequest` struct (HTTP binding) + - [ ] `UpdateRobotRequest` struct (HTTP binding) + - [ ] `NewRobotResponse()` - conversion from `api.RobotResponse` - [ ] Error response types ### 1.4 List Robots ⬜ -- [ ] `list.go` - GET /v1/robots +- [ ] `list.go` - GET /v1/agent/robots - [ ] Parse query params: `locale`, `status`, `keywords`, `page`, `pagesize` -- [ ] Call `robot/api.List()` +- [ ] Call `robot/api.ListRobots()` - [ ] Format response with localization - [ ] Test: `tests/robot/list_test.go` ### 1.5 Get Robot ⬜ -- [ ] `detail.go` - GET /v1/robots/:id +- [ ] `detail.go` - GET /v1/agent/robots/:id - [ ] Parse path param and `locale` query -- [ ] Call `robot/api.Get()` and `robot/api.Status()` +- [ ] Call `robot/api.GetRobot()` and `robot/api.GetRobotStatus()` - [ ] Format response with full config - [ ] Team access check - [ ] Test: `tests/robot/get_test.go` ### 1.6 Create Robot ⬜ -- [ ] POST /v1/robots handler -- [ ] Parse `CreateRobotRequest` -- [ ] Validate required fields -- [ ] Call `robot/api.Create()` +- [ ] POST /v1/agent/robots handler +- [ ] Parse HTTP request to `api.CreateRobotRequest` +- [ ] Apply `authInfo.WithCreateScope()` for permission fields +- [ ] Call `robot/api.CreateRobot()` - [ ] Return created robot - [ ] Test: `tests/robot/create_test.go` ### 1.7 Update Robot ⬜ -- [ ] PUT /v1/robots/:id handler -- [ ] Parse `UpdateRobotRequest` +- [ ] PUT /v1/agent/robots/:id handler +- [ ] Parse HTTP request to `api.UpdateRobotRequest` - [ ] Ownership/permission check -- [ ] Call `robot/api.Update()` +- [ ] Apply `authInfo.WithUpdateScope()` for permission fields +- [ ] Call `robot/api.UpdateRobot()` - [ ] Return updated robot - [ ] Test: `tests/robot/update_test.go` ### 1.8 Delete Robot ⬜ -- [ ] DELETE /v1/robots/:id handler +- [ ] DELETE /v1/agent/robots/:id handler - [ ] Ownership/permission check -- [ ] Call `robot/api.Remove()` +- [ ] Call `robot/api.RemoveRobot()` - [ ] Return success response - [ ] Test: `tests/robot/delete_test.go` ### 1.9 Utilities ⬜ - [ ] `utils.go` - helper functions - - [ ] `getLocale(r *http.Request)` - extract locale + - [ ] `getLocale(c *gin.Context)` - extract locale from query/header - [ ] `formatTime(t *time.Time)` - format to ISO string - [ ] `localizeString(value, locale)` - localization helper + - [ ] `applyAuthScope(authInfo, req)` - apply permission fields - [ ] `filter.go` - query filtering - - [ ] Parse query params to `ListQuery` - - [ ] Parse query params to `ExecutionQuery` + - [ ] Parse query params to `api.ListQuery` + - [ ] Parse query params to `api.ExecutionQuery` --- @@ -354,47 +371,56 @@ Need to add in `robot/`: > **Architecture:** Store layer handles CRUD, API layer handles business logic. > This enables reuse across Golang API, JSAPI, and Yao Process. -### robot/store/ Extensions (Core CRUD - implement first) +### robot/store/ Extensions (Core CRUD) -| Function | Phase | Risk | Description | -|----------|-------|------|-------------| -| `RobotStore.Save()` | 1 | 🟢 Low | Create/update robot member | -| `RobotStore.Get()` | 1 | 🟢 Low | Get robot by member_id | -| `RobotStore.List()` | 1 | 🟢 Low | List robots with filters | -| `RobotStore.Delete()` | 1 | 🟢 Low | Delete robot member | -| `RobotStore.UpdateConfig()` | 1 | 🟢 Low | Update config only | -| `ExecutionStore.ListResults()` | 3 | 🟢 Low | Query deliverables from executions | -| `ExecutionStore.GetResult()` | 3 | 🟢 Low | Get single deliverable | -| `ExecutionStore.ListActivities()` | 3 | 🟢 Low | Derive activities from history | -| Conversation store | 5 | 🟡 Medium | Temporary chat history (Deferred) | +| Function | Phase | Risk | Status | Description | +|----------|-------|------|--------|-------------| +| `RobotStore.Save()` | 1 | 🟢 Low | ✅ | Create/update robot member | +| `RobotStore.Get()` | 1 | 🟢 Low | ✅ | Get robot by member_id | +| `RobotStore.List()` | 1 | 🟢 Low | ✅ | List robots with filters | +| `RobotStore.Delete()` | 1 | 🟢 Low | ✅ | Delete robot member | +| `RobotStore.UpdateConfig()` | 1 | 🟢 Low | ✅ | Update config only | +| `RobotStore.UpdateStatus()` | 1 | 🟢 Low | ✅ | Update status only | +| `ExecutionStore.ListResults()` | 3 | 🟢 Low | ⬜ | Query deliverables from executions | +| `ExecutionStore.GetResult()` | 3 | 🟢 Low | ⬜ | Get single deliverable | +| `ExecutionStore.ListActivities()` | 3 | 🟢 Low | ⬜ | Derive activities from history | +| Conversation store | 5 | 🟡 Medium | ⬜ | Temporary chat history (Deferred) | ### robot/types/ Extensions -| Type/Field | Phase | Risk | Description | -|------------|-------|------|-------------| -| `Robot.Bio` | 1 | 🟢 Low | Add field, maps to `__yao.member.bio` | -| Execution name derivation | 2 | 🟢 Low | Derive in OpenAPI layer from goals or input | +| Type/Field | Phase | Risk | Status | Description | +|------------|-------|------|--------|-------------| +| `Robot.Bio` | 1 | 🟢 Low | ✅ | Add field, maps to `__yao.member.bio` | +| Execution name derivation | 2 | 🟢 Low | ⬜ | Derive in OpenAPI layer from goals or input | > **Note:** `Robot.Name` is NOT needed. Frontend `name` maps to existing `Robot.MemberID`. ### robot/cache/ Extensions -| File | Phase | Risk | Description | -|------|-------|------|-------------| -| `load.go` | 1 | 🟢 Low | Add `bio` to `memberFields` slice | +| File | Phase | Risk | Status | Description | +|------|-------|------|--------|-------------| +| `load.go` | 1 | 🟢 Low | ✅ | Add `bio` to `memberFields` slice | + +### robot/utils/ Extensions + +| File | Phase | Risk | Status | Description | +|------|-------|------|--------|-------------| +| `convert.go` | 1 | 🟢 Low | ✅ | Unified type conversion utilities | +| `convert_test.go` | 1 | 🟢 Low | ✅ | Tests for conversion utilities | ### robot/api/ Extensions (Thin wrappers calling store) -| Function | Phase | Risk | Description | -|----------|-------|------|-------------| -| `Create()` | 1 | 🟢 Low | Call `store.RobotStore.Save()` + cache refresh | -| `Update()` | 1 | 🟢 Low | Call `store.RobotStore.UpdateConfig()` + cache refresh | -| `Remove()` | 1 | 🟢 Low | Call `store.RobotStore.Delete()` + cache invalidate | -| `ListResults()` | 3 | 🟢 Low | Call `store.ExecutionStore.ListResults()` | -| `GetResult()` | 3 | 🟢 Low | Call `store.ExecutionStore.GetResult()` | -| `ListActivities()` | 3 | 🟢 Low | Call `store.ExecutionStore.ListActivities()` | -| `RetryExecution()` | 2 | 🟢 Low | Re-trigger with same input | -| `Chat()` | 5 | 🟡 Medium | Multi-turn conversation (Deferred) | +| Function | Phase | Risk | Status | Description | +|----------|-------|------|--------|-------------| +| `CreateRobot()` | 1 | 🟢 Low | ✅ | Call `store.RobotStore.Save()` + cache refresh | +| `UpdateRobot()` | 1 | 🟢 Low | ✅ | Partial update + cache refresh | +| `RemoveRobot()` | 1 | 🟢 Low | ✅ | Call `store.RobotStore.Delete()` + cache invalidate | +| `GetRobotResponse()` | 1 | 🟢 Low | ✅ | Get robot as API response | +| `ListResults()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.ListResults()` | +| `GetResult()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.GetResult()` | +| `ListActivities()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.ListActivities()` | +| `RetryExecution()` | 2 | 🟢 Low | ⬜ | Re-trigger with same input | +| `Chat()` | 5 | 🟡 Medium | ⬜ | Multi-turn conversation (Deferred) | ### Event System (Phase 6 - Deferred) @@ -441,7 +467,7 @@ yao/openapi/tests/robot/ | Phase | Risk | Status | Description | |-------|------|--------|-------------| -| 1. Core CRUD | 🟢 | ⬜ | Basic robot management | +| 1. Core CRUD | 🟢 | 🟡 | Basic robot management (Backend ✅, OpenAPI ⬜) | | 2. Execution | 🟢 | ⬜ | Execution listing, control, trigger/intervene | | 3. Results/Activities | 🟢 | ⬜ | Deliverables and activity feed | | 4. i18n | 🟢 | ⬜ | Locale parameter support | @@ -450,6 +476,21 @@ yao/openapi/tests/robot/ Legend: ⬜ Not started | 🟡 In progress | ✅ Complete | 🟢 Low Risk | 🟡 Medium Risk +### Phase 1 Detailed Status + +| Component | Status | Notes | +|-----------|--------|-------| +| `types.Robot.Bio` | ✅ | Field added | +| `cache/load.go` | ✅ | `bio` in memberFields | +| `store/robot.go` | ✅ | Full CRUD with permission fields | +| `store/robot_test.go` | ✅ | Integration tests | +| `api/robot.go` | ✅ | Create/Update/Remove/GetResponse | +| `api/types.go` | ✅ | Request/Response types, AuthScope | +| `api/robot_test.go` | ✅ | API tests | +| `utils/convert.go` | ✅ | Type conversion utilities | +| `utils/convert_test.go` | ✅ | Unit tests | +| `openapi/agent/robot/` | ⬜ | HTTP handlers (next step) | + --- ## Quick Reference