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"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
"github.com/yaoapp/gou/model"
|
"github.com/yaoapp/gou/model"
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/yao/agent/robot/store"
|
"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
|
// CreateRobot creates a new robot member
|
||||||
// Calls store.RobotStore.Save() and refreshes cache
|
// 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) {
|
func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, error) {
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if req.MemberID == "" {
|
|
||||||
return nil, fmt.Errorf("member_id is required")
|
|
||||||
}
|
|
||||||
if req.TeamID == "" {
|
if req.TeamID == "" {
|
||||||
return nil, fmt.Errorf("team_id is required")
|
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")
|
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
|
// Check if robot already exists
|
||||||
existing, err := robotStore.Get(context.Background(), req.MemberID)
|
existing, err := robotStore.Get(context.Background(), req.MemberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -376,10 +384,12 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh cache if manager is running
|
// 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()
|
mgr, err := getManager()
|
||||||
if err == nil && mgr != nil {
|
if err == nil && mgr != nil {
|
||||||
// Load the new robot into cache
|
_ = mgr.Cache().Refresh(ctx, req.MemberID)
|
||||||
_, _ = mgr.Cache().LoadByID(ctx, req.MemberID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the created robot as response
|
// 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
|
// 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()
|
mgr, err := getManager()
|
||||||
if err == nil && mgr != nil {
|
if err == nil && mgr != nil {
|
||||||
// Remove old entry and reload
|
_ = mgr.Cache().Refresh(ctx, memberID) // Ignore error, database is already saved
|
||||||
mgr.Cache().Remove(memberID)
|
|
||||||
_, _ = mgr.Cache().LoadByID(ctx, memberID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the updated robot as response
|
// Return the updated robot as response
|
||||||
|
|
@ -585,3 +596,54 @@ func recordToResponse(record *store.RobotRecord) *RobotResponse {
|
||||||
UpdatedAt: record.UpdatedAt,
|
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)
|
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{
|
req := &api.CreateRobotRequest{
|
||||||
MemberID: "",
|
MemberID: "",
|
||||||
TeamID: "team_001",
|
TeamID: "team_001",
|
||||||
DisplayName: "Test Robot",
|
DisplayName: "Test Robot Auto ID",
|
||||||
}
|
}
|
||||||
result, err := api.CreateRobot(ctx, req)
|
result, err := api.CreateRobot(ctx, req)
|
||||||
assert.Error(t, err)
|
require.NoError(t, err)
|
||||||
assert.Nil(t, result)
|
require.NotNil(t, result)
|
||||||
assert.Contains(t, err.Error(), "member_id is required")
|
|
||||||
|
// 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) {
|
t.Run("returns_error_for_empty_team_id", func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -120,9 +120,9 @@ type AuthScope struct {
|
||||||
|
|
||||||
// CreateRobotRequest - request for CreateRobot()
|
// CreateRobotRequest - request for CreateRobot()
|
||||||
type CreateRobotRequest struct {
|
type CreateRobotRequest struct {
|
||||||
// Required fields
|
// Identity (member_id is optional - auto-generated if not provided)
|
||||||
MemberID string `json:"member_id"` // Unique robot identifier
|
MemberID string `json:"member_id,omitempty"` // Unique robot identifier (auto-generated if empty)
|
||||||
TeamID string `json:"team_id"` // Team ID
|
TeamID string `json:"team_id"` // Team ID (required)
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
DisplayName string `json:"display_name,omitempty"` // Display name
|
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)
|
// TriggerManual manually triggers a robot execution (for testing or API calls)
|
||||||
// This bypasses clock checking and directly submits to pool
|
// 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) {
|
func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
if !m.started {
|
if !m.started {
|
||||||
|
|
@ -389,10 +390,10 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
||||||
}
|
}
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
// Get robot from cache
|
// Get robot from cache, or lazy-load if not found
|
||||||
robot := m.cache.Get(memberID)
|
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, memberID)
|
||||||
if robot == nil {
|
if err != nil {
|
||||||
return "", types.ErrRobotNotFound
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check robot status
|
// Check robot status
|
||||||
|
|
@ -410,9 +411,18 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
||||||
// Submit to pool
|
// Submit to pool
|
||||||
execID, err := m.pool.Submit(ctx, robot, trigger, data)
|
execID, err := m.pool.Submit(ctx, robot, trigger, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// If lazy-loaded and submission failed, remove from cache
|
||||||
|
if lazyLoaded {
|
||||||
|
m.cache.Remove(memberID)
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For lazy-loaded robots, schedule cleanup after execution completes
|
||||||
|
if lazyLoaded {
|
||||||
|
m.scheduleCleanup(robot)
|
||||||
|
}
|
||||||
|
|
||||||
return execID, nil
|
return execID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,6 +430,7 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
||||||
|
|
||||||
// Intervene processes a human intervention request
|
// Intervene processes a human intervention request
|
||||||
// Human intervention skips P0 (inspiration) and goes directly to P1 (goals)
|
// 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) {
|
func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
if !m.started {
|
if !m.started {
|
||||||
|
|
@ -433,10 +444,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get robot from cache
|
// Get robot from cache, or lazy-load if not found
|
||||||
robot := m.cache.Get(req.MemberID)
|
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
|
||||||
if robot == nil {
|
if err != nil {
|
||||||
return nil, types.ErrRobotNotFound
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check robot status
|
// 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
|
// Handle plan.add action - schedule for later
|
||||||
if req.Action == types.ActionPlanAdd && req.PlanTime != nil {
|
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)
|
// TODO: Add to plan queue (Phase 11.3)
|
||||||
return &types.ExecutionResult{
|
return &types.ExecutionResult{
|
||||||
Status: types.ExecPending,
|
Status: types.ExecPending,
|
||||||
|
|
@ -473,12 +488,21 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
||||||
// Submit to pool with executor mode
|
// Submit to pool with executor mode
|
||||||
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerHuman, triggerInput, executorMode)
|
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerHuman, triggerInput, executorMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// If lazy-loaded and submission failed, remove from cache
|
||||||
|
if lazyLoaded {
|
||||||
|
m.cache.Remove(req.MemberID)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track execution for pause/resume/stop
|
// Track execution for pause/resume/stop
|
||||||
m.execController.Track(execID, req.MemberID, req.TeamID)
|
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{
|
return &types.ExecutionResult{
|
||||||
ExecutionID: execID,
|
ExecutionID: execID,
|
||||||
Status: types.ExecPending,
|
Status: types.ExecPending,
|
||||||
|
|
@ -488,6 +512,7 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
||||||
|
|
||||||
// HandleEvent processes an event trigger request
|
// HandleEvent processes an event trigger request
|
||||||
// Event trigger skips P0 (inspiration) and goes directly to P1 (goals)
|
// 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) {
|
func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
if !m.started {
|
if !m.started {
|
||||||
|
|
@ -501,10 +526,10 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get robot from cache
|
// Get robot from cache, or lazy-load if not found
|
||||||
robot := m.cache.Get(req.MemberID)
|
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
|
||||||
if robot == nil {
|
if err != nil {
|
||||||
return nil, types.ErrRobotNotFound
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check robot status
|
// Check robot status
|
||||||
|
|
@ -528,12 +553,21 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
|
||||||
// Submit to pool with executor mode
|
// Submit to pool with executor mode
|
||||||
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerEvent, triggerInput, executorMode)
|
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerEvent, triggerInput, executorMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// If lazy-loaded and submission failed, remove from cache
|
||||||
|
if lazyLoaded {
|
||||||
|
m.cache.Remove(req.MemberID)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track execution for pause/resume/stop
|
// Track execution for pause/resume/stop
|
||||||
m.execController.Track(execID, req.MemberID, "")
|
m.execController.Track(execID, req.MemberID, "")
|
||||||
|
|
||||||
|
// For lazy-loaded robots, schedule cleanup after execution completes
|
||||||
|
if lazyLoaded {
|
||||||
|
m.scheduleCleanup(robot)
|
||||||
|
}
|
||||||
|
|
||||||
return &types.ExecutionResult{
|
return &types.ExecutionResult{
|
||||||
ExecutionID: execID,
|
ExecutionID: execID,
|
||||||
Status: types.ExecPending,
|
Status: types.ExecPending,
|
||||||
|
|
@ -579,6 +613,70 @@ func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledE
|
||||||
|
|
||||||
// ==================== Helper Methods ====================
|
// ==================== 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
|
// resolveExecutorMode determines the executor mode to use
|
||||||
// Priority: request > robot config > default (standard)
|
// Priority: request > robot config > default (standard)
|
||||||
func (m *Manager) resolveExecutorMode(requestMode types.ExecutorMode, robot *types.Robot) types.ExecutorMode {
|
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
|
// cleanupTestRobots removes all test robot records
|
||||||
func cleanupTestRobots(t *testing.T) {
|
func cleanupTestRobots(t *testing.T) {
|
||||||
qb := capsule.Query()
|
qb := capsule.Query()
|
||||||
|
|
@ -1417,6 +1683,10 @@ func cleanupTestRobots(t *testing.T) {
|
||||||
"robot_test_manager_intervene_disabled",
|
"robot_test_manager_intervene_disabled",
|
||||||
"robot_test_manager_event",
|
"robot_test_manager_event",
|
||||||
"robot_test_manager_event_disabled",
|
"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 {
|
for _, id := range testRobotIDs {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -12,6 +13,7 @@ import (
|
||||||
"github.com/yaoapp/gou/process"
|
"github.com/yaoapp/gou/process"
|
||||||
"github.com/yaoapp/kun/exception"
|
"github.com/yaoapp/kun/exception"
|
||||||
"github.com/yaoapp/yao/agent"
|
"github.com/yaoapp/yao/agent"
|
||||||
|
robotapi "github.com/yaoapp/yao/agent/robot/api"
|
||||||
"github.com/yaoapp/yao/aigc"
|
"github.com/yaoapp/yao/aigc"
|
||||||
"github.com/yaoapp/yao/api"
|
"github.com/yaoapp/yao/api"
|
||||||
"github.com/yaoapp/yao/attachment"
|
"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})
|
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 {
|
for name, hook := range LoadHooks {
|
||||||
err = hook(cfg)
|
err = hook(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -396,6 +409,13 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
|
||||||
func Unload() (err error) {
|
func Unload() (err error) {
|
||||||
defer func() { err = exception.Catch(recover()) }()
|
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
|
// Stop Runtime
|
||||||
err = runtime.Stop()
|
err = runtime.Stop()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,14 @@
|
||||||
└─ Page Integration (Robot list, detail, create, edit, delete) ✅
|
└─ Page Integration (Robot list, detail, create, edit, delete) ✅
|
||||||
└─ UI/UX (CreatureLoading, bubble animations) ✅
|
└─ 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
|
🟢 Phase 2: Execution Management
|
||||||
Backend → SDK → Page Integration
|
Backend → SDK → Page Integration
|
||||||
└─ List, Get, Control executions, Trigger/Intervene
|
└─ List, Get, Control executions, Trigger/Intervene
|
||||||
|
|
@ -65,6 +73,7 @@
|
||||||
|
|
||||||
#### API Layer (Thin wrappers calling store)
|
#### API Layer (Thin wrappers calling store)
|
||||||
- [x] Implement `api.CreateRobot()` - call `store.RobotStore.Save()` + cache refresh
|
- [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.UpdateRobot()` - partial update + cache refresh
|
||||||
- [x] Implement `api.RemoveRobot()` - call `store.RobotStore.Delete()` + cache invalidate
|
- [x] Implement `api.RemoveRobot()` - call `store.RobotStore.Delete()` + cache invalidate
|
||||||
- [x] Implement `api.GetRobotResponse()` - get robot as API response
|
- [x] Implement `api.GetRobotResponse()` - get robot as API response
|
||||||
|
|
@ -118,6 +127,7 @@
|
||||||
|
|
||||||
- [x] POST /v1/agent/robots handler
|
- [x] POST /v1/agent/robots handler
|
||||||
- [x] Parse HTTP request to `CreateRobotRequest`
|
- [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] Apply `AuthScope` with permission fields (CreatedBy, TeamID, TenantID)
|
||||||
- [x] Call `robot/api.CreateRobot()`
|
- [x] Call `robot/api.CreateRobot()`
|
||||||
- [x] Return created robot (201 Created)
|
- [x] Return created robot (201 Created)
|
||||||
|
|
@ -621,6 +631,7 @@ yao/openapi/tests/robot/
|
||||||
|-------|------|---------|----------|-------------|
|
|-------|------|---------|----------|-------------|
|
||||||
| 1. Core CRUD | 🟢 | ✅ | ✅ | Robot CRUD endpoints |
|
| 1. Core CRUD | 🟢 | ✅ | ✅ | Robot CRUD endpoints |
|
||||||
| 1-FE Frontend Integration | 🟢 | - | ✅ | SDK ✅, Page Integration ✅, UI/UX ✅ |
|
| 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 |
|
| 2. Execution | 🟢 | ⬜ | ⬜ | Execution listing, control, trigger |
|
||||||
| 3. Results/Activities | 🟢 | ⬜ | ⬜ | Deliverables and activity feed |
|
| 3. Results/Activities | 🟢 | ⬜ | ⬜ | Deliverables and activity feed |
|
||||||
| 4. i18n | 🟢 | ⬜ | ⬜ | Locale parameter support |
|
| 4. i18n | 🟢 | ⬜ | ⬜ | Locale parameter support |
|
||||||
|
|
|
||||||
|
|
@ -148,14 +148,6 @@ func CreateRobot(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// 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 == "" {
|
if req.DisplayName == "" {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrInvalidRequest.Code,
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
|
@ -165,6 +157,21 @@ func CreateRobot(c *gin.Context) {
|
||||||
return
|
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:
|
// Determine effective team_id:
|
||||||
// - If user has a team selected (authInfo.TeamID), use it
|
// - If user has a team selected (authInfo.TeamID), use it
|
||||||
// - Otherwise, for personal users, use user_id as team_id
|
// - Otherwise, for personal users, use user_id as team_id
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ import (
|
||||||
|
|
||||||
// CreateRobotRequest - HTTP request for creating a robot
|
// CreateRobotRequest - HTTP request for creating a robot
|
||||||
type CreateRobotRequest struct {
|
type CreateRobotRequest struct {
|
||||||
// Required fields
|
// Identity (member_id is optional - auto-generated if not provided)
|
||||||
MemberID string `json:"member_id" binding:"required"` // Unique robot identifier
|
MemberID string `json:"member_id,omitempty"` // Unique robot identifier (optional, auto-generated if empty)
|
||||||
TeamID string `json:"team_id" binding:"required"` // Team ID
|
TeamID string `json:"team_id,omitempty"` // Team ID (optional, defaults to auth team or user_id)
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
DisplayName string `json:"display_name" binding:"required"` // Display name
|
DisplayName string `json:"display_name" binding:"required"` // Display name
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
package robot
|
package robot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetLocale extracts locale from request
|
// GetLocale extracts locale from request
|
||||||
|
|
@ -41,3 +45,57 @@ func ParseBoolValue(value string) *bool {
|
||||||
}
|
}
|
||||||
return nil
|
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