Enhance Robot API with Bio Field and CRUD Operations
- Added a `Bio` field to the `Robot` structure, allowing for a description of the robot. - Updated the `cleanupAPITestRobots` function to delete robots with member IDs starting with both "robot_api_" and "api_robot_". - Implemented new API functions for creating, updating, and removing robots, ensuring proper validation and cache management. - Enhanced request and response types in `api/types.go` to include the new `Bio` field. - Added comprehensive tests for the new CRUD operations in `robot_test.go`, ensuring robust validation and error handling.
This commit is contained in:
parent
cf2a98ccb4
commit
df5836d8cc
11 changed files with 3089 additions and 149 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
agent/robot/cache/load.go
vendored
2
agent/robot/cache/load.go
vendored
|
|
@ -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
|
||||
|
|
|
|||
634
agent/robot/store/robot.go
Normal file
634
agent/robot/store/robot.go
Normal file
|
|
@ -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
|
||||
}
|
||||
578
agent/robot/store/robot_test.go
Normal file
578
agent/robot/store/robot_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,384 @@ package utils
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ==================== To<Type> 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<Type> 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
|
||||
}
|
||||
|
|
|
|||
567
agent/robot/utils/convert_test.go
Normal file
567
agent/robot/utils/convert_test.go
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
package utils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/utils"
|
||||
)
|
||||
|
||||
// ==================== To<Type> 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<Type> 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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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<Type>` functions (ToBool, ToInt, ToFloat64, ToTimestamp, ToJSONValue)
|
||||
- [x] Implement `Get<Type>` 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue