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:
Max 2026-01-22 17:31:36 +08:00
parent bb7638f1e5
commit 36ac190637
10 changed files with 571 additions and 39 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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