Enhance Robot Cache Implementation and Documentation
- Marked the Cache Implementation as complete in TODO.md, confirming all tasks are finished with comprehensive integration tests. - Updated cache.go to provide a thread-safe in-memory cache for Robot instances, improving performance and reliability. - Enhanced the Load method to include pagination and configurable model name, ensuring efficient data handling. - Added detailed comments and structured code for better readability and maintainability across the cache implementation. - Improved validation and error handling in various utility functions to ensure robustness in data processing.
This commit is contained in:
parent
435cb8b934
commit
f330a92a2b
15 changed files with 1391 additions and 616 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -201,12 +201,17 @@ This phase delivers a fully working scheduling pipeline:
|
|||
Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) → Job
|
||||
```
|
||||
|
||||
### 3.1 Cache Implementation
|
||||
### ✅ 3.1 Cache Implementation (COMPLETE)
|
||||
|
||||
- [ ] `cache/cache.go` - Cache struct with thread-safe map
|
||||
- [ ] `cache/load.go` - load robots from `__yao.member` where `member_type='robot'` and `autonomous_mode=true`
|
||||
- [ ] `cache/refresh.go` - refresh single robot, periodic full refresh (every hour)
|
||||
- [ ] Test: load/refresh with real DB
|
||||
- [x] `cache/cache.go` - Cache struct with thread-safe map
|
||||
- [x] `cache/load.go` - load robots from `__yao.member` where `member_type='robot'` and `autonomous_mode=true`
|
||||
- [x] Implemented pagination (100 robots per page)
|
||||
- [x] Configurable model name via `SetMemberModel()`
|
||||
- [x] `cache/refresh.go` - refresh single robot, periodic full refresh (every hour)
|
||||
- [x] Test: load/refresh with real DB
|
||||
- [x] Created comprehensive integration tests with real database
|
||||
- [x] Tests cover Load, LoadByID, Refresh, ListByTeam, GetByStatus
|
||||
- [x] All tests passing with proper cleanup
|
||||
|
||||
### 3.2 Pool Implementation
|
||||
|
||||
|
|
|
|||
19
agent/robot/cache/cache.go
vendored
19
agent/robot/cache/cache.go
vendored
|
|
@ -7,7 +7,7 @@ import (
|
|||
)
|
||||
|
||||
// Cache implements types.Cache interface
|
||||
// This is a stub implementation for Phase 2
|
||||
// Thread-safe in-memory cache for Robot instances
|
||||
type Cache struct {
|
||||
robots map[string]*types.Robot // memberID -> Robot
|
||||
byTeam map[string][]string // teamID -> memberIDs
|
||||
|
|
@ -22,12 +22,6 @@ func New() *Cache {
|
|||
}
|
||||
}
|
||||
|
||||
// Load loads all active robots from database
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (c *Cache) Load(ctx *types.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a robot by member ID
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (c *Cache) Get(memberID string) *types.Robot {
|
||||
|
|
@ -37,7 +31,6 @@ func (c *Cache) Get(memberID string) *types.Robot {
|
|||
}
|
||||
|
||||
// List returns all robots for a team
|
||||
// Stub: returns empty slice (will be implemented in Phase 3)
|
||||
func (c *Cache) List(teamID string) []*types.Robot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
|
@ -52,14 +45,14 @@ func (c *Cache) List(teamID string) []*types.Robot {
|
|||
return robots
|
||||
}
|
||||
|
||||
// Refresh refreshes a single robot's config from database
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
|
||||
return nil
|
||||
}
|
||||
// Note: Refresh is implemented in refresh.go
|
||||
|
||||
// Add adds or updates a robot in cache
|
||||
func (c *Cache) Add(robot *types.Robot) {
|
||||
if robot == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
|
|
|
|||
345
agent/robot/cache/cache_test.go
vendored
Normal file
345
agent/robot/cache/cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/xun/capsule"
|
||||
"github.com/yaoapp/yao/agent/robot/cache"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestCacheLoad tests loading all active robots from database
|
||||
func TestCacheLoad(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Clean up any existing test data first
|
||||
cleanupTestRobots(t)
|
||||
|
||||
// Create test robots in database
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load all robots
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Count should be at least 2 (may have other robots in DB)
|
||||
count := c.Count()
|
||||
assert.GreaterOrEqual(t, count, 2, "Should load at least 2 active autonomous robots")
|
||||
|
||||
// Verify first robot
|
||||
robot1 := c.Get("robot_test_sales_001")
|
||||
assert.NotNil(t, robot1, "Sales bot should be loaded")
|
||||
if robot1 == nil {
|
||||
t.Fatal("robot_test_sales_001 not found in cache")
|
||||
}
|
||||
assert.Equal(t, "robot_test_sales_001", robot1.MemberID)
|
||||
assert.Equal(t, "team_test_cache_001", robot1.TeamID)
|
||||
assert.Equal(t, "Test Sales Bot", robot1.DisplayName)
|
||||
assert.Equal(t, types.RobotIdle, robot1.Status)
|
||||
assert.True(t, robot1.AutonomousMode)
|
||||
assert.NotNil(t, robot1.Config, "Robot config should be parsed")
|
||||
assert.NotNil(t, robot1.Config.Identity, "Identity should be parsed")
|
||||
assert.Equal(t, "Sales Manager", robot1.Config.Identity.Role)
|
||||
assert.Equal(t, 3, robot1.Config.Quota.GetMax())
|
||||
|
||||
// Verify second robot
|
||||
robot2 := c.Get("robot_test_support_002")
|
||||
assert.NotNil(t, robot2, "Support bot should be loaded")
|
||||
assert.Equal(t, "robot_test_support_002", robot2.MemberID)
|
||||
assert.Equal(t, "Test Support Bot", robot2.DisplayName)
|
||||
assert.NotNil(t, robot2.Config)
|
||||
assert.Equal(t, "Customer Support", robot2.Config.Identity.Role)
|
||||
assert.Equal(t, 2, robot2.Config.Quota.GetMax())
|
||||
|
||||
// Verify inactive robot is not loaded
|
||||
robot3 := c.Get("robot_test_inactive_003")
|
||||
assert.Nil(t, robot3, "Inactive robot should not be loaded")
|
||||
}
|
||||
|
||||
// TestCacheLoadByID tests loading a single robot by member ID
|
||||
func TestCacheLoadByID(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("load existing robot", func(t *testing.T) {
|
||||
robot, err := c.LoadByID(ctx, "robot_test_sales_001")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, robot)
|
||||
assert.Equal(t, "robot_test_sales_001", robot.MemberID)
|
||||
assert.Equal(t, "Test Sales Bot", robot.DisplayName)
|
||||
assert.NotNil(t, robot.Config)
|
||||
})
|
||||
|
||||
t.Run("load non-existent robot", func(t *testing.T) {
|
||||
robot, err := c.LoadByID(ctx, "robot_nonexistent")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrRobotNotFound, err)
|
||||
assert.Nil(t, robot)
|
||||
})
|
||||
|
||||
t.Run("load inactive robot by ID", func(t *testing.T) {
|
||||
// LoadByID doesn't filter by status, so it should load
|
||||
robot, err := c.LoadByID(ctx, "robot_test_inactive_003")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, robot)
|
||||
assert.Equal(t, "robot_test_inactive_003", robot.MemberID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCacheRefresh tests refreshing a single robot from database
|
||||
func TestCacheRefresh(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load initial data
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("refresh existing robot", func(t *testing.T) {
|
||||
err := c.Refresh(ctx, "robot_test_sales_001")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Robot should still be in cache
|
||||
robot := c.Get("robot_test_sales_001")
|
||||
assert.NotNil(t, robot)
|
||||
})
|
||||
|
||||
t.Run("refresh removes non-existent robot", func(t *testing.T) {
|
||||
// Add a fake robot to cache
|
||||
c.Add(&types.Robot{MemberID: "robot_test_fake", TeamID: "team_test_cache_001"})
|
||||
assert.NotNil(t, c.Get("robot_test_fake"))
|
||||
|
||||
// Refresh should remove it
|
||||
err := c.Refresh(ctx, "robot_test_fake")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, c.Get("robot_test_fake"), "Non-existent robot should be removed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCacheListByTeam tests listing robots by team
|
||||
func TestCacheListByTeam(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load all robots
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// List robots by team
|
||||
robots := c.List("team_test_cache_001")
|
||||
assert.Len(t, robots, 2, "Should have 2 robots in team_test_cache_001")
|
||||
|
||||
// List robots for non-existent team
|
||||
robots = c.List("team_nonexistent")
|
||||
assert.Len(t, robots, 0, "Non-existent team should have no robots")
|
||||
}
|
||||
|
||||
// TestCacheGetByStatus tests getting robots by status
|
||||
func TestCacheGetByStatus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load all robots
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get idle robots (may have others in DB)
|
||||
idle := c.GetIdle()
|
||||
assert.GreaterOrEqual(t, len(idle), 2, "Should have at least 2 idle robots")
|
||||
|
||||
// Verify our test robots are not working
|
||||
testRobot1 := c.Get("robot_test_sales_001")
|
||||
testRobot2 := c.Get("robot_test_support_002")
|
||||
assert.Equal(t, types.RobotIdle, testRobot1.Status, "Test robot 1 should be idle")
|
||||
assert.Equal(t, types.RobotIdle, testRobot2.Status, "Test robot 2 should be idle")
|
||||
}
|
||||
|
||||
// setupTestRobots creates 3 test robot records in database
|
||||
func setupTestRobots(t *testing.T) {
|
||||
// Get the actual table name from model
|
||||
m := model.Select("__yao.member")
|
||||
tableName := m.MetaData.Table.Name
|
||||
|
||||
qb := capsule.Query()
|
||||
|
||||
// Robot 1: Sales Bot (active, autonomous)
|
||||
robotConfig1 := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "Sales Manager",
|
||||
"duties": []string{"Manage leads", "Follow up customers"},
|
||||
"rules": []string{"Be professional", "Reply within 24h"},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 3,
|
||||
"queue": 15,
|
||||
"priority": 7,
|
||||
},
|
||||
"clock": map[string]interface{}{
|
||||
"mode": "times",
|
||||
"times": []string{"09:00", "14:00"},
|
||||
"tz": "Asia/Shanghai",
|
||||
},
|
||||
}
|
||||
config1JSON, _ := json.Marshal(robotConfig1)
|
||||
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_sales_001",
|
||||
"team_id": "team_test_cache_001",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test Sales Bot",
|
||||
"system_prompt": "You are a professional sales manager assistant.",
|
||||
"status": "active",
|
||||
"role_id": "member", // required field
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(config1JSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_sales_001: %v", err)
|
||||
}
|
||||
|
||||
// Robot 2: Support Bot (active, autonomous)
|
||||
robotConfig2 := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "Customer Support",
|
||||
"duties": []string{"Answer questions", "Resolve issues"},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 2,
|
||||
"queue": 10,
|
||||
"priority": 5,
|
||||
},
|
||||
"clock": map[string]interface{}{
|
||||
"mode": "interval",
|
||||
"every": "1h",
|
||||
},
|
||||
}
|
||||
config2JSON, _ := json.Marshal(robotConfig2)
|
||||
|
||||
err = qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_support_002",
|
||||
"team_id": "team_test_cache_001",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test Support Bot",
|
||||
"system_prompt": "You are a helpful customer support assistant.",
|
||||
"status": "active",
|
||||
"role_id": "member", // required field
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(config2JSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_support_002: %v", err)
|
||||
}
|
||||
|
||||
// Robot 3: Inactive robot (should not be loaded by Load())
|
||||
err = qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_inactive_003",
|
||||
"team_id": "team_test_cache_001",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test Inactive Bot",
|
||||
"status": "inactive",
|
||||
"role_id": "member", // required field
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "paused",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_inactive_003: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupTestRobots removes test robot records
|
||||
func cleanupTestRobots(t *testing.T) {
|
||||
qb := capsule.Query()
|
||||
|
||||
// Use the member model to perform soft delete
|
||||
m := model.Select("__yao.member")
|
||||
|
||||
// Delete test robots
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: "robot_test_sales_001"},
|
||||
},
|
||||
})
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: "robot_test_support_002"},
|
||||
},
|
||||
})
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: "robot_test_inactive_003"},
|
||||
},
|
||||
})
|
||||
|
||||
// Hard delete from database (cleanup for next test run)
|
||||
m2 := model.Select("__yao.member")
|
||||
tableName2 := m2.MetaData.Table.Name
|
||||
qb.Table(tableName2).Where("member_id", "robot_test_sales_001").Delete()
|
||||
qb.Table(tableName2).Where("member_id", "robot_test_support_002").Delete()
|
||||
qb.Table(tableName2).Where("member_id", "robot_test_inactive_003").Delete()
|
||||
}
|
||||
115
agent/robot/cache/load.go
vendored
Normal file
115
agent/robot/cache/load.go
vendored
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// memberModel is the model name for member table
|
||||
// Can be changed via SetMemberModel() during system initialization
|
||||
var memberModel = "__yao.member"
|
||||
|
||||
// memberFields are the fields to select when loading robots
|
||||
var memberFields = []interface{}{
|
||||
"id",
|
||||
"member_id",
|
||||
"team_id",
|
||||
"display_name",
|
||||
"system_prompt",
|
||||
"robot_status",
|
||||
"autonomous_mode",
|
||||
"robot_config",
|
||||
}
|
||||
|
||||
// SetMemberModel sets the member model name
|
||||
// Call this during system initialization to override the default
|
||||
func SetMemberModel(model string) {
|
||||
if model != "" {
|
||||
memberModel = model
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads all active robots from database with pagination
|
||||
// Query: member_type='robot' AND autonomous_mode=true AND status='active'
|
||||
func (c *Cache) Load(ctx *types.Context) error {
|
||||
m := model.Select(memberModel)
|
||||
|
||||
// Clear existing cache first
|
||||
c.mu.Lock()
|
||||
c.robots = make(map[string]*types.Robot)
|
||||
c.byTeam = make(map[string][]string)
|
||||
c.mu.Unlock()
|
||||
|
||||
// Paginate to handle large number of robots
|
||||
page := 1
|
||||
pageSize := 100 // load 100 robots per page
|
||||
totalLoaded := 0
|
||||
|
||||
for {
|
||||
// Query with pagination
|
||||
result, err := m.Paginate(model.QueryParam{
|
||||
Select: memberFields,
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_type", Value: "robot"},
|
||||
{Column: "autonomous_mode", Value: true},
|
||||
{Column: "status", Value: "active"},
|
||||
},
|
||||
}, page, pageSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load robots (page %d): %w", page, err)
|
||||
}
|
||||
|
||||
// Extract records from pagination result
|
||||
data, ok := result.Get("data").([]maps.MapStr)
|
||||
if !ok || len(data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse and add each robot
|
||||
for _, record := range data {
|
||||
robot, err := types.NewRobotFromMap(map[string]interface{}(record))
|
||||
if err != nil {
|
||||
// Log error but continue loading other robots
|
||||
continue
|
||||
}
|
||||
c.Add(robot)
|
||||
totalLoaded++
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
total, _ := result.Get("total").(int)
|
||||
if totalLoaded >= total {
|
||||
break
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadByID loads a single robot from database by member ID
|
||||
func (c *Cache) LoadByID(ctx *types.Context, memberID string) (*types.Robot, error) {
|
||||
m := model.Select(memberModel)
|
||||
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: memberFields,
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: memberID},
|
||||
{Column: "member_type", Value: "robot"},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load robot %s: %w", memberID, err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
return types.NewRobotFromMap(map[string]interface{}(records[0]))
|
||||
}
|
||||
142
agent/robot/cache/refresh.go
vendored
Normal file
142
agent/robot/cache/refresh.go
vendored
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RefreshConfig holds refresh configuration
|
||||
type RefreshConfig struct {
|
||||
Interval time.Duration // full refresh interval (default: 1 hour)
|
||||
}
|
||||
|
||||
// DefaultRefreshConfig returns default refresh configuration
|
||||
func DefaultRefreshConfig() *RefreshConfig {
|
||||
return &RefreshConfig{
|
||||
Interval: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// refreshState holds the refresh goroutine state
|
||||
type refreshState struct {
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var refresher = &refreshState{}
|
||||
|
||||
// Refresh refreshes a single robot's config from database
|
||||
func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
|
||||
robot, err := c.LoadByID(ctx, memberID)
|
||||
if err != nil {
|
||||
// If robot not found or no longer autonomous, remove from cache
|
||||
if err == types.ErrRobotNotFound {
|
||||
c.Remove(memberID)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if robot is still active and autonomous
|
||||
if !robot.AutonomousMode {
|
||||
c.Remove(memberID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update cache
|
||||
c.Add(robot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartAutoRefresh starts periodic full refresh
|
||||
func (c *Cache) StartAutoRefresh(ctx *types.Context, config *RefreshConfig) {
|
||||
if config == nil {
|
||||
config = DefaultRefreshConfig()
|
||||
}
|
||||
|
||||
refresher.mu.Lock()
|
||||
defer refresher.mu.Unlock()
|
||||
|
||||
// Stop existing refresher if any
|
||||
if refresher.done != nil {
|
||||
close(refresher.done)
|
||||
}
|
||||
|
||||
refresher.ticker = time.NewTicker(config.Interval)
|
||||
refresher.done = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-refresher.done:
|
||||
refresher.ticker.Stop()
|
||||
return
|
||||
case <-refresher.ticker.C:
|
||||
// Perform full refresh
|
||||
_ = c.Load(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopAutoRefresh stops the periodic refresh
|
||||
func (c *Cache) StopAutoRefresh() {
|
||||
refresher.mu.Lock()
|
||||
defer refresher.mu.Unlock()
|
||||
|
||||
if refresher.done != nil {
|
||||
close(refresher.done)
|
||||
refresher.done = nil
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshAll reloads all robots from database
|
||||
func (c *Cache) RefreshAll(ctx *types.Context) error {
|
||||
return c.Load(ctx)
|
||||
}
|
||||
|
||||
// Count returns the number of cached robots
|
||||
func (c *Cache) Count() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.robots)
|
||||
}
|
||||
|
||||
// ListAll returns all cached robots (across all teams)
|
||||
func (c *Cache) ListAll() []*types.Robot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
robots := make([]*types.Robot, 0, len(c.robots))
|
||||
for _, robot := range c.robots {
|
||||
robots = append(robots, robot)
|
||||
}
|
||||
return robots
|
||||
}
|
||||
|
||||
// GetByStatus returns robots with the specified status
|
||||
func (c *Cache) GetByStatus(status types.RobotStatus) []*types.Robot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
var robots []*types.Robot
|
||||
for _, robot := range c.robots {
|
||||
if robot.Status == status {
|
||||
robots = append(robots, robot)
|
||||
}
|
||||
}
|
||||
return robots
|
||||
}
|
||||
|
||||
// GetIdle returns all idle robots ready to execute
|
||||
func (c *Cache) GetIdle() []*types.Robot {
|
||||
return c.GetByStatus(types.RobotIdle)
|
||||
}
|
||||
|
||||
// GetWorking returns all currently working robots
|
||||
func (c *Cache) GetWorking() []*types.Robot {
|
||||
return c.GetByStatus(types.RobotWorking)
|
||||
}
|
||||
|
|
@ -5,11 +5,11 @@ import "time"
|
|||
// ClockContext - time context for P0 inspiration
|
||||
type ClockContext struct {
|
||||
Now time.Time `json:"now"`
|
||||
Hour int `json:"hour"` // 0-23
|
||||
DayOfWeek string `json:"day_of_week"` // Monday, Tuesday...
|
||||
DayOfMonth int `json:"day_of_month"` // 1-31
|
||||
WeekOfYear int `json:"week_of_year"` // 1-52
|
||||
Month int `json:"month"` // 1-12
|
||||
Hour int `json:"hour"` // 0-23
|
||||
DayOfWeek string `json:"day_of_week"` // Monday, Tuesday...
|
||||
DayOfMonth int `json:"day_of_month"` // 1-31
|
||||
WeekOfYear int `json:"week_of_year"` // 1-52
|
||||
Month int `json:"month"` // 1-12
|
||||
Year int `json:"year"`
|
||||
IsWeekend bool `json:"is_weekend"`
|
||||
IsMonthStart bool `json:"is_month_start"` // 1st-3rd
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package types
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config - robot_config in __yao.member
|
||||
type Config struct {
|
||||
|
|
@ -8,9 +11,9 @@ type Config struct {
|
|||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *Delivery `json:"delivery,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
|
|
@ -206,3 +209,44 @@ type Event struct {
|
|||
Source string `json:"source"` // webhook path or table name
|
||||
Filter map[string]interface{} `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// ParseConfig parses robot_config from various formats (string, []byte, map)
|
||||
func ParseConfig(data interface{}) (*Config, error) {
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var configBytes []byte
|
||||
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
configBytes = []byte(v)
|
||||
case []byte:
|
||||
if len(v) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
configBytes = v
|
||||
case map[string]interface{}:
|
||||
var err error
|
||||
configBytes, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
var err error
|
||||
configBytes, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(configBytes, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
// Context - robot execution context (lightweight)
|
||||
type Context struct {
|
||||
context.Context // embed standard context
|
||||
context.Context // embed standard context
|
||||
Auth *types.AuthorizedInfo `json:"auth,omitempty"` // reuse oauth AuthorizedInfo
|
||||
MemberID string `json:"member_id,omitempty"` // current robot member ID
|
||||
RequestID string `json:"request_id,omitempty"` // request trace ID
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ type RobotState struct {
|
|||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status RobotStatus `json:"status"`
|
||||
Running int `json:"running"` // current running execution count
|
||||
MaxRunning int `json:"max_running"` // max concurrent allowed
|
||||
Running int `json:"running"` // current running execution count
|
||||
MaxRunning int `json:"max_running"` // max concurrent allowed
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"` // list of running execution IDs
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package types
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -89,8 +90,8 @@ func (r *Robot) GetExecutions() []*Execution {
|
|||
// Each trigger creates a new Execution, mapped to a job.Job for monitoring
|
||||
// Relationship: 1 Execution = 1 job.Job
|
||||
type Execution struct {
|
||||
ID string `json:"id"` // unique execution ID
|
||||
MemberID string `json:"member_id"` // robot member ID
|
||||
ID string `json:"id"` // unique execution ID
|
||||
MemberID string `json:"member_id"` // robot member ID
|
||||
TeamID string `json:"team_id"`
|
||||
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
||||
StartTime time.Time `json:"start_time"`
|
||||
|
|
@ -148,11 +149,13 @@ type CurrentState struct {
|
|||
// Example:
|
||||
// ## Goals
|
||||
// 1. [High] Analyze sales data and identify trends
|
||||
// - Reason: Sales up 50%, need to understand why
|
||||
// - Reason: Sales up 50%, need to understand why
|
||||
//
|
||||
// 2. [Normal] Prepare weekly report for manager
|
||||
// - Reason: Friday 5pm, weekly report due
|
||||
// - Reason: Friday 5pm, weekly report due
|
||||
//
|
||||
// 3. [Low] Update CRM with new leads
|
||||
// - Reason: 3 pending leads from yesterday
|
||||
// - Reason: 3 pending leads from yesterday
|
||||
type Goals struct {
|
||||
Content string `json:"content"` // markdown text
|
||||
}
|
||||
|
|
@ -201,3 +204,76 @@ type LearningEntry struct {
|
|||
Tags []string `json:"tags,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// NewRobotFromMap creates a Robot from a map (typically from DB record)
|
||||
func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
|
||||
memberID := getString(m, "member_id")
|
||||
teamID := getString(m, "team_id")
|
||||
|
||||
// Validate required fields
|
||||
if memberID == "" || teamID == "" {
|
||||
return nil, fmt.Errorf("missing required fields: member_id or team_id")
|
||||
}
|
||||
|
||||
robot := &Robot{
|
||||
MemberID: memberID,
|
||||
TeamID: teamID,
|
||||
DisplayName: getString(m, "display_name"),
|
||||
SystemPrompt: getString(m, "system_prompt"),
|
||||
AutonomousMode: getBool(m, "autonomous_mode"),
|
||||
}
|
||||
|
||||
// Parse robot_status
|
||||
if status := getString(m, "robot_status"); status != "" {
|
||||
robot.Status = RobotStatus(status)
|
||||
} else {
|
||||
robot.Status = RobotIdle
|
||||
}
|
||||
|
||||
// Parse robot_config JSON
|
||||
if configData, ok := m["robot_config"]; ok && configData != nil {
|
||||
config, err := ParseConfig(configData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse robot_config: %w", err)
|
||||
}
|
||||
robot.Config = config
|
||||
}
|
||||
|
||||
return robot, nil
|
||||
}
|
||||
|
||||
// getString safely gets a string value from map
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getBool safely gets a bool value from map
|
||||
func getBool(m map[string]interface{}, key string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case int:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case float64:
|
||||
return b != 0
|
||||
case string:
|
||||
return b == "true" || b == "1"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,3 +89,58 @@ func CloneMap(m map[string]interface{}) map[string]interface{} {
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetString safely gets a string value from map
|
||||
func GetString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
return ToString(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetBool safely gets a bool value from map
|
||||
func GetBool(m map[string]interface{}, key string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case int:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case float64:
|
||||
return b != 0
|
||||
case string:
|
||||
return b == "true" || b == "1"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetInt safely gets an int value from map
|
||||
func GetInt(m map[string]interface{}, key string) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case string:
|
||||
var i int
|
||||
fmt.Sscanf(n, "%d", &i)
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ func NextScheduledTime(now time.Time, timeStr string, days []string, loc *time.L
|
|||
}
|
||||
|
||||
nowInLoc := now.In(loc)
|
||||
|
||||
|
||||
// Start from today at the specified time
|
||||
next := time.Date(nowInLoc.Year(), nowInLoc.Month(), nowInLoc.Day(), hour, minute, 0, 0, loc)
|
||||
|
||||
|
|
|
|||
|
|
@ -145,10 +145,10 @@ func TestToJSON(t *testing.T) {
|
|||
|
||||
func TestFromJSON(t *testing.T) {
|
||||
jsonStr := `{"name":"test","age":30}`
|
||||
|
||||
|
||||
var result map[string]interface{}
|
||||
err := utils.FromJSON(jsonStr, &result)
|
||||
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test", result["name"])
|
||||
assert.Equal(t, float64(30), result["age"]) // JSON numbers are float64
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
var (
|
||||
// Email regex pattern
|
||||
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
|
||||
// Time pattern (HH:MM)
|
||||
timeRegex = regexp.MustCompile(`^([01]?[0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
)
|
||||
|
|
@ -33,7 +33,7 @@ func ValidateRequired(fieldName string, value interface{}) error {
|
|||
if value == nil {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if IsEmpty(v) {
|
||||
|
|
@ -48,7 +48,7 @@ func ValidateRequired(fieldName string, value interface{}) error {
|
|||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue