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:
Max 2026-01-22 11:50:25 +08:00
parent cf2a98ccb4
commit df5836d8cc
11 changed files with 3089 additions and 149 deletions

View file

@ -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

View file

@ -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,
}
}

View file

@ -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)

View file

@ -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

View file

@ -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
View 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
}

View 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)
}
}

View file

@ -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"),

View file

@ -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
}

View 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")
})
}

View file

@ -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