Implement Auto-Generation of Member ID in Robot API
- Updated the CreateRobot API to auto-generate the member_id if not provided, enhancing usability and ensuring unique identifiers. - Revised CreateRobotRequest structure to make member_id optional, aligning with the new auto-generation logic. - Added a new function for generating unique member IDs with collision detection, ensuring compliance with existing ID patterns. - Enhanced unit tests to validate the new behavior, ensuring robust error handling and proper ID generation. - Updated related OpenAPI documentation to reflect changes in request structure and behavior.
This commit is contained in:
parent
bb7638f1e5
commit
36ac190637
10 changed files with 571 additions and 39 deletions
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
|
|
@ -282,11 +283,9 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult {
|
|||
|
||||
// CreateRobot creates a new robot member
|
||||
// Calls store.RobotStore.Save() and refreshes cache
|
||||
// If member_id is not provided, it will be auto-generated
|
||||
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")
|
||||
}
|
||||
|
|
@ -294,6 +293,15 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e
|
|||
return nil, fmt.Errorf("display_name is required")
|
||||
}
|
||||
|
||||
// Generate member_id if not provided
|
||||
if req.MemberID == "" {
|
||||
generatedID, err := generateMemberID(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate member_id: %w", err)
|
||||
}
|
||||
req.MemberID = generatedID
|
||||
}
|
||||
|
||||
// Check if robot already exists
|
||||
existing, err := robotStore.Get(context.Background(), req.MemberID)
|
||||
if err != nil {
|
||||
|
|
@ -376,10 +384,12 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e
|
|||
}
|
||||
|
||||
// Refresh cache if manager is running
|
||||
// Use Refresh() which handles autonomous_mode correctly:
|
||||
// - If autonomous_mode=true: adds to cache for scheduling
|
||||
// - If autonomous_mode=false: does not add to cache
|
||||
mgr, err := getManager()
|
||||
if err == nil && mgr != nil {
|
||||
// Load the new robot into cache
|
||||
_, _ = mgr.Cache().LoadByID(ctx, req.MemberID)
|
||||
_ = mgr.Cache().Refresh(ctx, req.MemberID)
|
||||
}
|
||||
|
||||
// Return the created robot as response
|
||||
|
|
@ -486,11 +496,12 @@ func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (
|
|||
}
|
||||
|
||||
// Refresh cache if manager is running
|
||||
// Use Refresh() which handles autonomous_mode correctly:
|
||||
// - If autonomous_mode=true: adds to cache for scheduling
|
||||
// - If autonomous_mode=false: removes from cache
|
||||
mgr, err := getManager()
|
||||
if err == nil && mgr != nil {
|
||||
// Remove old entry and reload
|
||||
mgr.Cache().Remove(memberID)
|
||||
_, _ = mgr.Cache().LoadByID(ctx, memberID)
|
||||
_ = mgr.Cache().Refresh(ctx, memberID) // Ignore error, database is already saved
|
||||
}
|
||||
|
||||
// Return the updated robot as response
|
||||
|
|
@ -585,3 +596,54 @@ func recordToResponse(record *store.RobotRecord) *RobotResponse {
|
|||
UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Member ID Generation ====================
|
||||
|
||||
// generateMemberID generates a unique member_id with collision detection
|
||||
// Uses 12-digit numeric ID to match existing pattern in openapi/oauth/providers/user
|
||||
func generateMemberID(ctx context.Context) (string, error) {
|
||||
const maxRetries = 10
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
// Generate 12-digit numeric ID
|
||||
id, err := gonanoid.Generate("0123456789", 12)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate member_id: %w", err)
|
||||
}
|
||||
|
||||
// Check if ID already exists
|
||||
exists, err := memberIDExists(ctx, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check member_id existence: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return id, nil
|
||||
}
|
||||
// ID exists, retry
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
// memberIDExists checks if a member_id already exists in the database
|
||||
func memberIDExists(ctx context.Context, memberID string) (bool, error) {
|
||||
m := model.Select(memberModel)
|
||||
if m == nil {
|
||||
return false, fmt.Errorf("model %s not found", memberModel)
|
||||
}
|
||||
|
||||
members, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"id"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: memberID},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(members) > 0, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,16 +115,22 @@ func TestCreateRobotValidation(t *testing.T) {
|
|||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("returns_error_for_empty_member_id", func(t *testing.T) {
|
||||
t.Run("auto_generates_member_id_when_empty", func(t *testing.T) {
|
||||
req := &api.CreateRobotRequest{
|
||||
MemberID: "",
|
||||
TeamID: "team_001",
|
||||
DisplayName: "Test Robot",
|
||||
DisplayName: "Test Robot Auto ID",
|
||||
}
|
||||
result, err := api.CreateRobot(ctx, req)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Verify member_id was auto-generated (12-digit numeric)
|
||||
assert.NotEmpty(t, result.MemberID)
|
||||
assert.Len(t, result.MemberID, 12, "Auto-generated member_id should be 12 digits")
|
||||
|
||||
// Cleanup
|
||||
_ = api.RemoveRobot(ctx, result.MemberID)
|
||||
})
|
||||
|
||||
t.Run("returns_error_for_empty_team_id", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -120,9 +120,9 @@ type AuthScope struct {
|
|||
|
||||
// CreateRobotRequest - request for CreateRobot()
|
||||
type CreateRobotRequest struct {
|
||||
// Required fields
|
||||
MemberID string `json:"member_id"` // Unique robot identifier
|
||||
TeamID string `json:"team_id"` // Team ID
|
||||
// Identity (member_id is optional - auto-generated if not provided)
|
||||
MemberID string `json:"member_id,omitempty"` // Unique robot identifier (auto-generated if empty)
|
||||
TeamID string `json:"team_id"` // Team ID (required)
|
||||
|
||||
// Profile
|
||||
DisplayName string `json:"display_name,omitempty"` // Display name
|
||||
|
|
|
|||
|
|
@ -381,6 +381,7 @@ func (m *Manager) matchesDay(clock *types.Clock, now time.Time) bool {
|
|||
|
||||
// TriggerManual manually triggers a robot execution (for testing or API calls)
|
||||
// This bypasses clock checking and directly submits to pool
|
||||
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
||||
func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error) {
|
||||
m.mu.RLock()
|
||||
if !m.started {
|
||||
|
|
@ -389,10 +390,10 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
|||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Get robot from cache
|
||||
robot := m.cache.Get(memberID)
|
||||
if robot == nil {
|
||||
return "", types.ErrRobotNotFound
|
||||
// Get robot from cache, or lazy-load if not found
|
||||
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, memberID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Check robot status
|
||||
|
|
@ -410,9 +411,18 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
|||
// Submit to pool
|
||||
execID, err := m.pool.Submit(ctx, robot, trigger, data)
|
||||
if err != nil {
|
||||
// If lazy-loaded and submission failed, remove from cache
|
||||
if lazyLoaded {
|
||||
m.cache.Remove(memberID)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// For lazy-loaded robots, schedule cleanup after execution completes
|
||||
if lazyLoaded {
|
||||
m.scheduleCleanup(robot)
|
||||
}
|
||||
|
||||
return execID, nil
|
||||
}
|
||||
|
||||
|
|
@ -420,6 +430,7 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
|||
|
||||
// Intervene processes a human intervention request
|
||||
// Human intervention skips P0 (inspiration) and goes directly to P1 (goals)
|
||||
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
||||
func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
|
||||
m.mu.RLock()
|
||||
if !m.started {
|
||||
|
|
@ -433,10 +444,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Get robot from cache
|
||||
robot := m.cache.Get(req.MemberID)
|
||||
if robot == nil {
|
||||
return nil, types.ErrRobotNotFound
|
||||
// Get robot from cache, or lazy-load if not found
|
||||
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check robot status
|
||||
|
|
@ -460,6 +471,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
|
||||
// Handle plan.add action - schedule for later
|
||||
if req.Action == types.ActionPlanAdd && req.PlanTime != nil {
|
||||
// If lazy-loaded but not executing, remove immediately
|
||||
if lazyLoaded {
|
||||
m.cache.Remove(req.MemberID)
|
||||
}
|
||||
// TODO: Add to plan queue (Phase 11.3)
|
||||
return &types.ExecutionResult{
|
||||
Status: types.ExecPending,
|
||||
|
|
@ -473,12 +488,21 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
// Submit to pool with executor mode
|
||||
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerHuman, triggerInput, executorMode)
|
||||
if err != nil {
|
||||
// If lazy-loaded and submission failed, remove from cache
|
||||
if lazyLoaded {
|
||||
m.cache.Remove(req.MemberID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track execution for pause/resume/stop
|
||||
m.execController.Track(execID, req.MemberID, req.TeamID)
|
||||
|
||||
// For lazy-loaded robots, schedule cleanup after execution completes
|
||||
if lazyLoaded {
|
||||
m.scheduleCleanup(robot)
|
||||
}
|
||||
|
||||
return &types.ExecutionResult{
|
||||
ExecutionID: execID,
|
||||
Status: types.ExecPending,
|
||||
|
|
@ -488,6 +512,7 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
|
||||
// HandleEvent processes an event trigger request
|
||||
// Event trigger skips P0 (inspiration) and goes directly to P1 (goals)
|
||||
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
||||
func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
|
||||
m.mu.RLock()
|
||||
if !m.started {
|
||||
|
|
@ -501,10 +526,10 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Get robot from cache
|
||||
robot := m.cache.Get(req.MemberID)
|
||||
if robot == nil {
|
||||
return nil, types.ErrRobotNotFound
|
||||
// Get robot from cache, or lazy-load if not found
|
||||
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check robot status
|
||||
|
|
@ -528,12 +553,21 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
|
|||
// Submit to pool with executor mode
|
||||
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerEvent, triggerInput, executorMode)
|
||||
if err != nil {
|
||||
// If lazy-loaded and submission failed, remove from cache
|
||||
if lazyLoaded {
|
||||
m.cache.Remove(req.MemberID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track execution for pause/resume/stop
|
||||
m.execController.Track(execID, req.MemberID, "")
|
||||
|
||||
// For lazy-loaded robots, schedule cleanup after execution completes
|
||||
if lazyLoaded {
|
||||
m.scheduleCleanup(robot)
|
||||
}
|
||||
|
||||
return &types.ExecutionResult{
|
||||
ExecutionID: execID,
|
||||
Status: types.ExecPending,
|
||||
|
|
@ -579,6 +613,70 @@ func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledE
|
|||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
// getOrLoadRobot gets a robot from cache, or lazy-loads from DB if not found
|
||||
// Returns: robot, wasLazyLoaded, error
|
||||
func (m *Manager) getOrLoadRobot(ctx *types.Context, memberID string) (*types.Robot, bool, error) {
|
||||
// Try cache first
|
||||
robot := m.cache.Get(memberID)
|
||||
if robot != nil {
|
||||
return robot, false, nil
|
||||
}
|
||||
|
||||
// Not in cache - lazy load from database
|
||||
robot, err := m.cache.LoadByID(ctx, memberID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Add to cache temporarily for execution tracking
|
||||
m.cache.Add(robot)
|
||||
|
||||
// Return with lazyLoaded=true to indicate cleanup needed after execution
|
||||
return robot, true, nil
|
||||
}
|
||||
|
||||
// scheduleCleanup schedules removal of a lazy-loaded robot after all executions complete
|
||||
// This runs in a goroutine that monitors the robot's execution count
|
||||
func (m *Manager) scheduleCleanup(robot *types.Robot) {
|
||||
go func() {
|
||||
memberID := robot.MemberID
|
||||
|
||||
// Poll every 5 seconds to check if all executions are done
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Timeout after 24 hours to prevent memory leaks
|
||||
timeout := time.After(24 * time.Hour)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
// Timeout - force cleanup
|
||||
m.cache.Remove(memberID)
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
// Check if robot still exists in cache
|
||||
r := m.cache.Get(memberID)
|
||||
if r == nil {
|
||||
// Already removed
|
||||
return
|
||||
}
|
||||
|
||||
// Check if all executions are done
|
||||
if r.RunningCount() == 0 {
|
||||
// Only remove if still non-autonomous
|
||||
// (user might have changed it during execution)
|
||||
if !r.AutonomousMode {
|
||||
m.cache.Remove(memberID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// resolveExecutorMode determines the executor mode to use
|
||||
// Priority: request > robot config > default (standard)
|
||||
func (m *Manager) resolveExecutorMode(requestMode types.ExecutorMode, robot *types.Robot) types.ExecutorMode {
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,272 @@ func setupTestRobotsWithEventConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== Lazy Load Tests for Non-Autonomous Robots ====================
|
||||
|
||||
// TestManagerLazyLoadNonAutonomous tests that non-autonomous robots are lazy-loaded on demand
|
||||
// and automatically cleaned up after execution completes
|
||||
func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobotsWithNonAutonomous(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
t.Run("non-autonomous robot not in cache on startup", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
// Non-autonomous robot should NOT be in cache
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.Nil(t, robot, "Non-autonomous robot should not be pre-loaded into cache")
|
||||
|
||||
// Autonomous robot SHOULD be in cache
|
||||
autoRobot := m.Cache().Get("robot_test_manager_times")
|
||||
assert.NotNil(t, autoRobot, "Autonomous robot should be in cache")
|
||||
})
|
||||
|
||||
t.Run("TriggerManual lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand"))
|
||||
|
||||
// Trigger the non-autonomous robot manually
|
||||
execID, err := m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, execID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache")
|
||||
assert.Equal(t, "robot_test_manager_on_demand", robot.MemberID)
|
||||
assert.False(t, robot.AutonomousMode)
|
||||
})
|
||||
|
||||
t.Run("Intervene lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_intervene"))
|
||||
|
||||
// Intervene on the non-autonomous robot
|
||||
req := &types.InterveneRequest{
|
||||
TeamID: "team_test_manager",
|
||||
MemberID: "robot_test_manager_on_demand_intervene",
|
||||
Action: types.ActionTaskAdd,
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Test lazy load via intervene"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := m.Intervene(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result.ExecutionID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand_intervene")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via Intervene")
|
||||
})
|
||||
|
||||
t.Run("HandleEvent lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_event"))
|
||||
|
||||
// Send event to the non-autonomous robot
|
||||
req := &types.EventRequest{
|
||||
MemberID: "robot_test_manager_on_demand_event",
|
||||
Source: "webhook",
|
||||
EventType: "data.updated",
|
||||
Data: map[string]interface{}{"test": true},
|
||||
}
|
||||
|
||||
result, err := m.HandleEvent(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result.ExecutionID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand_event")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via HandleEvent")
|
||||
})
|
||||
|
||||
t.Run("lazy-loaded robot is cleaned up after execution completes", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Trigger the non-autonomous robot
|
||||
_, err = m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Robot should be in cache immediately after trigger
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.NotNil(t, robot, "Robot should be in cache after trigger")
|
||||
|
||||
// Wait for execution to complete and cleanup to happen
|
||||
// The stub executor completes quickly, and cleanup runs every 5 seconds
|
||||
// We wait up to 10 seconds for the cleanup goroutine to remove the robot
|
||||
var removed bool
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if m.Cache().Get("robot_test_manager_on_demand") == nil {
|
||||
removed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, removed, "Non-autonomous robot should be removed from cache after execution completes")
|
||||
})
|
||||
|
||||
t.Run("trigger non-existent robot returns error", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Try to trigger a robot that doesn't exist in DB
|
||||
_, err = m.TriggerManual(ctx, "robot_nonexistent_xyz", types.TriggerHuman, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrRobotNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// setupTestRobotsWithNonAutonomous creates test robots including non-autonomous ones
|
||||
func setupTestRobotsWithNonAutonomous(t *testing.T) {
|
||||
// First setup the autonomous robots
|
||||
setupTestRobotsWithClockConfig(t)
|
||||
|
||||
// Add non-autonomous robots
|
||||
qb := capsule.Query()
|
||||
m := model.Select("__yao.member")
|
||||
tableName := m.MetaData.Table.Name
|
||||
|
||||
// Non-autonomous robot 1: for TriggerManual test
|
||||
robotConfigOnDemand := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "On-Demand Robot",
|
||||
},
|
||||
"triggers": map[string]interface{}{
|
||||
"clock": map[string]interface{}{"enabled": false},
|
||||
"intervene": map[string]interface{}{"enabled": true},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 2,
|
||||
"queue": 5,
|
||||
},
|
||||
}
|
||||
configOnDemandJSON, _ := json.Marshal(robotConfigOnDemand)
|
||||
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_manager_on_demand",
|
||||
"team_id": "team_test_manager",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test On-Demand Robot",
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": false, // Non-autonomous!
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configOnDemandJSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_manager_on_demand: %v", err)
|
||||
}
|
||||
|
||||
// Non-autonomous robot 2: for Intervene test
|
||||
robotConfigOnDemandIntervene := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "On-Demand Intervene Robot",
|
||||
},
|
||||
"triggers": map[string]interface{}{
|
||||
"clock": map[string]interface{}{"enabled": false},
|
||||
"intervene": map[string]interface{}{"enabled": true},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 2,
|
||||
},
|
||||
}
|
||||
configOnDemandInterveneJSON, _ := json.Marshal(robotConfigOnDemandIntervene)
|
||||
|
||||
err = qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_manager_on_demand_intervene",
|
||||
"team_id": "team_test_manager",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test On-Demand Intervene Robot",
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": false, // Non-autonomous!
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configOnDemandInterveneJSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_manager_on_demand_intervene: %v", err)
|
||||
}
|
||||
|
||||
// Non-autonomous robot 3: for HandleEvent test
|
||||
robotConfigOnDemandEvent := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "On-Demand Event Robot",
|
||||
},
|
||||
"triggers": map[string]interface{}{
|
||||
"clock": map[string]interface{}{"enabled": false},
|
||||
"event": map[string]interface{}{"enabled": true},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 2,
|
||||
},
|
||||
}
|
||||
configOnDemandEventJSON, _ := json.Marshal(robotConfigOnDemandEvent)
|
||||
|
||||
err = qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_manager_on_demand_event",
|
||||
"team_id": "team_test_manager",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test On-Demand Event Robot",
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": false, // Non-autonomous!
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configOnDemandEventJSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_manager_on_demand_event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupTestRobots removes all test robot records
|
||||
func cleanupTestRobots(t *testing.T) {
|
||||
qb := capsule.Query()
|
||||
|
|
@ -1417,6 +1683,10 @@ func cleanupTestRobots(t *testing.T) {
|
|||
"robot_test_manager_intervene_disabled",
|
||||
"robot_test_manager_event",
|
||||
"robot_test_manager_event_disabled",
|
||||
// Non-autonomous robots
|
||||
"robot_test_manager_on_demand",
|
||||
"robot_test_manager_on_demand_intervene",
|
||||
"robot_test_manager_on_demand_event",
|
||||
}
|
||||
|
||||
for _, id := range testRobotIDs {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package engine
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/agent"
|
||||
robotapi "github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/aigc"
|
||||
"github.com/yaoapp/yao/api"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
|
|
@ -355,6 +357,17 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
|
|||
warnings = append(warnings, Warning{Widget: "Agent", Error: err})
|
||||
}
|
||||
|
||||
// Start Robot Agent System (async, non-blocking)
|
||||
// This starts the robot scheduler for autonomous mode robots
|
||||
go func() {
|
||||
if err := robotapi.Start(); err != nil {
|
||||
// Log warning but don't block application startup
|
||||
// The robot system can operate without the manager running
|
||||
// (API calls will fall back to direct database queries)
|
||||
log.Printf("[Robot Agent] Warning: failed to start robot agent system: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
for name, hook := range LoadHooks {
|
||||
err = hook(cfg)
|
||||
if err != nil {
|
||||
|
|
@ -396,6 +409,13 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
|
|||
func Unload() (err error) {
|
||||
defer func() { err = exception.Catch(recover()) }()
|
||||
|
||||
// Stop Robot Agent System
|
||||
if robotapi.IsRunning() {
|
||||
if stopErr := robotapi.Stop(); stopErr != nil {
|
||||
log.Printf("[Robot Agent] Warning: failed to stop robot agent system: %v", stopErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop Runtime
|
||||
err = runtime.Stop()
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@
|
|||
└─ Page Integration (Robot list, detail, create, edit, delete) ✅
|
||||
└─ UI/UX (CreatureLoading, bubble animations) ✅
|
||||
|
||||
✅ Phase 1.5: Robot Manager Lifecycle ✅ [Completed]
|
||||
└─ Auto-start Manager on Yao startup (async)
|
||||
└─ Auto-reload cache on robot update
|
||||
└─ Auto-remove from cache on robot delete
|
||||
└─ Graceful shutdown on Yao unload
|
||||
└─ Lazy-load for non-autonomous robots (load on trigger, unload after execution)
|
||||
└─ Unit tests: TestManagerLazyLoadNonAutonomous (6 test cases)
|
||||
|
||||
🟢 Phase 2: Execution Management
|
||||
Backend → SDK → Page Integration
|
||||
└─ List, Get, Control executions, Trigger/Intervene
|
||||
|
|
@ -65,6 +73,7 @@
|
|||
|
||||
#### API Layer (Thin wrappers calling store)
|
||||
- [x] Implement `api.CreateRobot()` - call `store.RobotStore.Save()` + cache refresh
|
||||
- [x] Auto-generate `member_id` if not provided (12-digit numeric, matches existing pattern)
|
||||
- [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
|
||||
|
|
@ -118,6 +127,7 @@
|
|||
|
||||
- [x] POST /v1/agent/robots handler
|
||||
- [x] Parse HTTP request to `CreateRobotRequest`
|
||||
- [x] Auto-generate `member_id` if not provided (12-digit numeric, consistent with existing API)
|
||||
- [x] Apply `AuthScope` with permission fields (CreatedBy, TeamID, TenantID)
|
||||
- [x] Call `robot/api.CreateRobot()`
|
||||
- [x] Return created robot (201 Created)
|
||||
|
|
@ -621,6 +631,7 @@ yao/openapi/tests/robot/
|
|||
|-------|------|---------|----------|-------------|
|
||||
| 1. Core CRUD | 🟢 | ✅ | ✅ | Robot CRUD endpoints |
|
||||
| 1-FE Frontend Integration | 🟢 | - | ✅ | SDK ✅, Page Integration ✅, UI/UX ✅ |
|
||||
| 1.5 Manager Lifecycle | 🟢 | ✅ | - | Auto-start, auto-reload, graceful shutdown |
|
||||
| 2. Execution | 🟢 | ⬜ | ⬜ | Execution listing, control, trigger |
|
||||
| 3. Results/Activities | 🟢 | ⬜ | ⬜ | Deliverables and activity feed |
|
||||
| 4. i18n | 🟢 | ⬜ | ⬜ | Locale parameter support |
|
||||
|
|
|
|||
|
|
@ -148,14 +148,6 @@ func CreateRobot(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.MemberID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "member_id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
if req.DisplayName == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
|
|
@ -165,6 +157,21 @@ func CreateRobot(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Generate member_id if not provided (follows existing API pattern)
|
||||
if req.MemberID == "" {
|
||||
generatedID, err := GenerateMemberID(c.Request.Context())
|
||||
if err != nil {
|
||||
log.Error("Failed to generate member_id: %v", err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to generate member_id: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
req.MemberID = generatedID
|
||||
}
|
||||
|
||||
// Determine effective team_id:
|
||||
// - If user has a team selected (authInfo.TeamID), use it
|
||||
// - Otherwise, for personal users, use user_id as team_id
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import (
|
|||
|
||||
// CreateRobotRequest - HTTP request for creating a robot
|
||||
type CreateRobotRequest struct {
|
||||
// Required fields
|
||||
MemberID string `json:"member_id" binding:"required"` // Unique robot identifier
|
||||
TeamID string `json:"team_id" binding:"required"` // Team ID
|
||||
// Identity (member_id is optional - auto-generated if not provided)
|
||||
MemberID string `json:"member_id,omitempty"` // Unique robot identifier (optional, auto-generated if empty)
|
||||
TeamID string `json:"team_id,omitempty"` // Team ID (optional, defaults to auth team or user_id)
|
||||
|
||||
// Profile
|
||||
DisplayName string `json:"display_name" binding:"required"` // Display name
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/yaoapp/gou/model"
|
||||
)
|
||||
|
||||
// GetLocale extracts locale from request
|
||||
|
|
@ -41,3 +45,57 @@ func ParseBoolValue(value string) *bool {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== Member ID Generation ====================
|
||||
// Follows the same pattern as openapi/oauth/providers/user/utils.go
|
||||
|
||||
const memberModel = "__yao.member"
|
||||
|
||||
// GenerateMemberID generates a new unique member_id for robot creation
|
||||
// Uses numeric ID (12 characters) with collision detection
|
||||
func GenerateMemberID(ctx context.Context) (string, error) {
|
||||
const maxRetries = 10
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
// Generate 12-digit numeric ID (matches existing pattern)
|
||||
id, err := gonanoid.Generate("0123456789", 12)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate member_id: %w", err)
|
||||
}
|
||||
|
||||
// Check if ID already exists
|
||||
exists, err := memberIDExists(ctx, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check member_id existence: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return id, nil
|
||||
}
|
||||
// ID exists, retry
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
// memberIDExists checks if a member_id already exists in the database
|
||||
func memberIDExists(ctx context.Context, memberID string) (bool, error) {
|
||||
m := model.Select(memberModel)
|
||||
if m == nil {
|
||||
return false, fmt.Errorf("model %s not found", memberModel)
|
||||
}
|
||||
|
||||
members, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"id"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: memberID},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(members) > 0, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue