Merge pull request #1424 from trheyi/main
Refactor API Structure and Update TODO for MVP Integration
This commit is contained in:
commit
359107d33d
15 changed files with 2145 additions and 320 deletions
|
|
@ -1081,20 +1081,38 @@ type DeliveryContext struct {
|
|||
|
||||
---
|
||||
|
||||
## Phase 11: API & Integration
|
||||
## Phase 11: API & Integration (MVP)
|
||||
|
||||
**Goal:** Complete API implementation, end-to-end tests. Main flow: P0 → P1 → P2 → P3 → P4.
|
||||
**Goal:** Complete Go API and end-to-end tests. Main flow: P0 → P1 → P2 → P3 → P4.
|
||||
|
||||
**Depends on:** Phase 10 (P4 Delivery)
|
||||
|
||||
> **Note:** P5 Learning is an advanced feature (async, background, user-invisible).
|
||||
> Main flow works without it. Moved to Phase 12 (Advanced Features).
|
||||
> **Note:** Process handlers and JSAPI are optional wrappers, moved to Phase 12.
|
||||
> Go API is sufficient for MVP integration.
|
||||
|
||||
### 11.1 API Implementation
|
||||
### 11.1 Go API Implementation
|
||||
|
||||
- [ ] `api/api.go` - implement all Go API functions
|
||||
- [ ] `api/process.go` - implement all Process handlers
|
||||
- [ ] `api/jsapi.go` - implement JSAPI
|
||||
- [x] `api/types.go` - API request/response types
|
||||
- [x] `api/lifecycle.go` - manager lifecycle
|
||||
- [x] `Start()` / `Stop()` - manager lifecycle
|
||||
- [x] `StartWithConfig(config)` - start with custom config
|
||||
- [x] `IsRunning()` - check if system is running
|
||||
- [x] `api/robot.go` - robot query functions
|
||||
- [x] `GetRobot(memberID)` - get robot by member ID
|
||||
- [x] `ListRobots(query)` - list robots with filtering
|
||||
- [x] `GetRobotStatus(memberID)` - get robot runtime status
|
||||
- [x] `api/trigger.go` - trigger functions
|
||||
- [x] `Trigger(memberID, request)` - main trigger entry point
|
||||
- [x] `TriggerManual(memberID, triggerType, data)` - manual trigger for testing
|
||||
- [x] `Intervene(memberID, request)` - human intervention
|
||||
- [x] `HandleEvent(memberID, request)` - event trigger
|
||||
- [x] `api/execution.go` - execution query and control
|
||||
- [x] `GetExecution(execID)` - get execution by ID
|
||||
- [x] `ListExecutions(memberID, query)` - list executions
|
||||
- [x] `GetExecutionStatus(execID)` - get execution with runtime status
|
||||
- [x] `PauseExecution(execID)` / `ResumeExecution(execID)` / `StopExecution(execID)`
|
||||
- [x] `api/api.go` - package documentation
|
||||
- [x] Tests: `api/*_test.go` (black-box tests, 16 test cases)
|
||||
|
||||
### 11.2 End-to-End Tests
|
||||
|
||||
|
|
@ -1113,41 +1131,54 @@ type DeliveryContext struct {
|
|||
|
||||
## Phase 12: Advanced Features
|
||||
|
||||
**Goal:** Implement P5 Learning, dedup, semantic dedup, plan queue.
|
||||
**Goal:** P5 Learning, Process/JSAPI wrappers, dedup, plan queue.
|
||||
|
||||
> **Note:** These are optional advanced features. Main flow works without them.
|
||||
> **Note:** These are optional features. Main flow works without them.
|
||||
|
||||
### 12.1 P5 Learning Implementation
|
||||
### 12.1 Process & JSAPI Wrappers
|
||||
|
||||
> **Note:** These are convenience wrappers around Go API for Yao ecosystem integration.
|
||||
|
||||
- [ ] `api/process.go` - implement Process handlers
|
||||
- [ ] `robot.Start` / `robot.Stop`
|
||||
- [ ] `robot.Trigger` / `robot.Intervene` / `robot.HandleEvent`
|
||||
- [ ] `robot.Pause` / `robot.Resume` / `robot.Stop`
|
||||
- [ ] `robot.Get` / `robot.List`
|
||||
- [ ] `robot.GetExecution` / `robot.ListExecutions`
|
||||
- [ ] `api/jsapi.go` - implement JSAPI for JavaScript runtime
|
||||
- [ ] Tests for Process and JSAPI
|
||||
|
||||
### 12.3 P5 Learning Implementation
|
||||
|
||||
> **Background:** P5 Learning is async, runs after P4 Delivery completes.
|
||||
> User doesn't wait for it. Results stored in private KB for future reference.
|
||||
|
||||
#### 12.1.1 Learning Agent Setup
|
||||
#### 12.3.1 Learning Agent Setup
|
||||
|
||||
- [ ] `robot/learning/package.yao` - Learning Agent config
|
||||
- [ ] `robot/learning/prompts.yml` - learning prompts
|
||||
|
||||
#### 12.1.2 Store Implementation
|
||||
#### 12.3.2 Store Implementation
|
||||
|
||||
- [ ] `store/store.go` - Store interface and struct
|
||||
- [ ] `store/kb.go` - KB operations (create, save, search)
|
||||
- [ ] `store/learning.go` - save learning entries to private KB
|
||||
|
||||
#### 12.1.3 Implementation
|
||||
#### 12.3.3 Implementation
|
||||
|
||||
- [ ] `executor/learning.go` - `RunLearning(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/learning.go` - extract learnings from execution
|
||||
- [ ] `executor/learning.go` - call Learning Agent
|
||||
- [ ] `executor/learning.go` - save to private KB
|
||||
|
||||
#### 12.1.4 Tests
|
||||
#### 12.3.4 Tests
|
||||
|
||||
- [ ] `executor/learning_test.go` - P5 learning
|
||||
- [ ] Test: learnings extracted from execution
|
||||
- [ ] Test: learnings saved to KB
|
||||
- [ ] Test: KB can be queried for past learnings
|
||||
|
||||
### 12.2 Fast Dedup (Time-Window)
|
||||
### 12.5 Fast Dedup (Time-Window)
|
||||
|
||||
> **Note:** Manager has `// TODO: dedup check` comment placeholder. Integrate after implementation.
|
||||
|
||||
|
|
@ -1159,13 +1190,13 @@ type DeliveryContext struct {
|
|||
- [ ] Integrate into Manager.Tick()
|
||||
- [ ] Test: dedup check/mark, window expiry
|
||||
|
||||
### 12.3 Semantic Dedup
|
||||
### 12.6 Semantic Dedup
|
||||
|
||||
- [ ] `dedup/semantic.go` - call Dedup Agent for goal/task level dedup
|
||||
- [ ] Dedup Agent setup (`assistants/robot/dedup/`)
|
||||
- [ ] Test: semantic dedup with real LLM
|
||||
|
||||
### 12.4 Plan Queue
|
||||
### 12.7 Plan Queue
|
||||
|
||||
- [ ] `plan/plan.go` - plan queue implementation
|
||||
- [ ] Store planned tasks/goals
|
||||
|
|
@ -1294,8 +1325,8 @@ func TestWithLLM(t *testing.T) {
|
|||
| 8. P2 Tasks | ✅ | Task Planning Agent integration |
|
||||
| 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation |
|
||||
| 10. P4 Delivery | ✅ | Output delivery (email/webhook/process, notify future) |
|
||||
| 11. API & Integration | ⬜ | Complete API, end-to-end tests (main flow: P0→P1→P2→P3→P4) |
|
||||
| 12. Advanced | ⬜ | P5 Learning, dedup, plan queue, Sandbox mode |
|
||||
| 11. API & Integration | ⬜ | Go API, end-to-end tests (main flow: P0→P1→P2→P3→P4) |
|
||||
| 12. Advanced | ⬜ | Process/JSAPI, P5 Learning, dedup, plan queue, Sandbox |
|
||||
|
||||
Legend: ⬜ Not started | 🟡 In progress | ✅ Complete
|
||||
|
||||
|
|
|
|||
194
agent/robot/api/README.md
Normal file
194
agent/robot/api/README.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# Robot Agent Go API
|
||||
|
||||
Go API for managing autonomous robot agents.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
import "github.com/yaoapp/yao/agent/robot/api"
|
||||
|
||||
// Start system
|
||||
api.Start()
|
||||
defer api.Stop()
|
||||
|
||||
// Trigger execution
|
||||
result, _ := api.Trigger(ctx, "member_123", &api.TriggerRequest{
|
||||
Type: types.TriggerHuman,
|
||||
Action: types.ActionTaskAdd,
|
||||
Messages: []agentcontext.Message{{Role: "user", Content: "Analyze sales"}},
|
||||
})
|
||||
|
||||
// Check status
|
||||
exec, _ := api.GetExecution(ctx, result.JobID)
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```go
|
||||
api.Start() // Start with defaults
|
||||
api.StartWithConfig(config) // Start with custom config
|
||||
api.Stop() // Graceful shutdown
|
||||
api.IsRunning() // Check if running
|
||||
```
|
||||
|
||||
## Robot Query
|
||||
|
||||
```go
|
||||
// Get single robot
|
||||
robot, err := api.GetRobot(ctx, "member_123")
|
||||
|
||||
// List robots with filters
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_1",
|
||||
Status: types.RobotIdle,
|
||||
Keywords: "sales",
|
||||
ClockMode: types.ClockInterval,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
Order: "created_at desc",
|
||||
})
|
||||
|
||||
// Get runtime status
|
||||
state, err := api.GetRobotStatus(ctx, "member_123")
|
||||
// state.Running, state.MaxRunning, state.RunningIDs, state.LastRun, state.NextRun
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
### Human Intervention
|
||||
|
||||
```go
|
||||
result, err := api.Trigger(ctx, "member_123", &api.TriggerRequest{
|
||||
Type: types.TriggerHuman,
|
||||
Action: types.ActionTaskAdd,
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: "user", Content: "Generate weekly report"},
|
||||
},
|
||||
})
|
||||
// Or use shorthand:
|
||||
result, err := api.Intervene(ctx, "member_123", req)
|
||||
```
|
||||
|
||||
### Event Trigger
|
||||
|
||||
```go
|
||||
result, err := api.Trigger(ctx, "member_123", &api.TriggerRequest{
|
||||
Type: types.TriggerEvent,
|
||||
Source: types.EventWebhook,
|
||||
EventType: "order.created",
|
||||
Data: map[string]interface{}{"order_id": "12345"},
|
||||
})
|
||||
// Or use shorthand:
|
||||
result, err := api.HandleEvent(ctx, "member_123", req)
|
||||
```
|
||||
|
||||
### Manual Trigger (Testing)
|
||||
|
||||
```go
|
||||
result, err := api.TriggerManual(ctx, "member_123", types.TriggerClock, nil)
|
||||
```
|
||||
|
||||
## Execution Management
|
||||
|
||||
```go
|
||||
// Get execution by ID
|
||||
exec, err := api.GetExecution(ctx, "exec_abc123")
|
||||
|
||||
// List executions with filters
|
||||
result, err := api.ListExecutions(ctx, "member_123", &api.ExecutionQuery{
|
||||
Status: types.ExecRunning,
|
||||
Trigger: types.TriggerClock,
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
|
||||
// Get execution with runtime status
|
||||
exec, err := api.GetExecutionStatus(ctx, "exec_abc123")
|
||||
|
||||
// Control execution
|
||||
api.PauseExecution(ctx, "exec_abc123")
|
||||
api.ResumeExecution(ctx, "exec_abc123")
|
||||
api.StopExecution(ctx, "exec_abc123")
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### ListQuery
|
||||
|
||||
```go
|
||||
type ListQuery struct {
|
||||
TeamID string // Filter by team
|
||||
Status types.RobotStatus // Filter by status (idle|working|paused|error)
|
||||
Keywords string // Search in display_name
|
||||
ClockMode types.ClockMode // Filter by clock mode (times|interval|daemon)
|
||||
Page int // Page number (default: 1)
|
||||
PageSize int // Page size (default: 20, max: 100)
|
||||
Order string // Order by column (default: "created_at desc")
|
||||
}
|
||||
```
|
||||
|
||||
### TriggerRequest
|
||||
|
||||
```go
|
||||
type TriggerRequest struct {
|
||||
Type types.TriggerType // human | event | clock
|
||||
Action types.InterventionAction // task.add, goal.adjust, etc.
|
||||
Messages []agentcontext.Message // User input
|
||||
PlanAt *time.Time // Schedule for later
|
||||
InsertPosition InsertPosition // first | last | next | at
|
||||
AtIndex int // When InsertPosition = "at"
|
||||
Source types.EventSource // webhook | database
|
||||
EventType string // Event name
|
||||
Data map[string]interface{} // Event payload
|
||||
ExecutorMode types.ExecutorMode // standard | dryrun | sandbox
|
||||
}
|
||||
```
|
||||
|
||||
### TriggerResult
|
||||
|
||||
```go
|
||||
type TriggerResult struct {
|
||||
Accepted bool // Whether trigger was accepted
|
||||
Queued bool // Whether queued (vs immediate)
|
||||
Execution *types.Execution // Execution details
|
||||
JobID string // Execution ID for tracking
|
||||
Message string // Status message
|
||||
}
|
||||
```
|
||||
|
||||
### ExecutionQuery
|
||||
|
||||
```go
|
||||
type ExecutionQuery struct {
|
||||
Status types.ExecStatus // Filter by status
|
||||
Trigger types.TriggerType // Filter by trigger type
|
||||
Page int // Page number (default: 1)
|
||||
PageSize int // Page size (default: 20, max: 100)
|
||||
}
|
||||
```
|
||||
|
||||
### RobotState
|
||||
|
||||
```go
|
||||
type RobotState struct {
|
||||
MemberID string // Robot member ID
|
||||
TeamID string // Team ID
|
||||
DisplayName string // Display name
|
||||
Status types.RobotStatus // idle | working | paused | error
|
||||
Running int // Current running count
|
||||
MaxRunning int // Max concurrent limit
|
||||
LastRun *time.Time // Last execution time
|
||||
NextRun *time.Time // Next scheduled time
|
||||
RunningIDs []string // IDs of running executions
|
||||
}
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Functions |
|
||||
|------|-----------|
|
||||
| `lifecycle.go` | `Start`, `StartWithConfig`, `Stop`, `IsRunning` |
|
||||
| `robot.go` | `GetRobot`, `ListRobots`, `GetRobotStatus` |
|
||||
| `trigger.go` | `Trigger`, `TriggerManual`, `Intervene`, `HandleEvent` |
|
||||
| `execution.go` | `GetExecution`, `ListExecutions`, `GetExecutionStatus`, `PauseExecution`, `ResumeExecution`, `StopExecution` |
|
||||
| `types.go` | Type definitions |
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ==================== CRUD ====================
|
||||
|
||||
// Get returns a robot by member ID
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func Get(ctx *types.Context, memberID string) (*types.Robot, error) {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// List returns robots with pagination and filtering
|
||||
// Stub: returns empty result (will be implemented in Phase 10)
|
||||
func List(ctx *types.Context, query *ListQuery) (*ListResult, error) {
|
||||
return &ListResult{
|
||||
Data: []*types.Robot{},
|
||||
Total: 0,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create creates a new robot member
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func Create(ctx *types.Context, teamID string, req *CreateRequest) (*types.Robot, error) {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// Update updates robot config
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func Update(ctx *types.Context, memberID string, req *UpdateRequest) (*types.Robot, error) {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// Remove deletes a robot member
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func Remove(ctx *types.Context, memberID string) error {
|
||||
return types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// ==================== Status ====================
|
||||
|
||||
// Status returns current robot runtime state
|
||||
// Stub: returns empty state (will be implemented in Phase 10)
|
||||
func Status(ctx *types.Context, memberID string) (*RobotState, error) {
|
||||
return &RobotState{
|
||||
MemberID: memberID,
|
||||
Status: types.RobotIdle,
|
||||
Running: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates robot status (idle, paused, etc.)
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func UpdateStatus(ctx *types.Context, memberID string, status types.RobotStatus) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== Trigger ====================
|
||||
|
||||
// Trigger starts execution with specified trigger type and request
|
||||
// Stub: returns empty result (will be implemented in Phase 10)
|
||||
func Trigger(ctx *types.Context, memberID string, req *TriggerRequest) (*TriggerResult, error) {
|
||||
return &TriggerResult{
|
||||
Accepted: false,
|
||||
Message: "not implemented",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ==================== Execution ====================
|
||||
|
||||
// GetExecutions returns execution history
|
||||
// Stub: returns empty result (will be implemented in Phase 10)
|
||||
func GetExecutions(ctx *types.Context, memberID string, query *ExecutionQuery) (*ExecutionResult, error) {
|
||||
return &ExecutionResult{
|
||||
Data: []*types.Execution{},
|
||||
Total: 0,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetExecution returns a specific execution by ID
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func GetExecution(ctx *types.Context, execID string) (*types.Execution, error) {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// Pause pauses a running execution
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func Pause(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume resumes a paused execution
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func Resume(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops a running execution
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func Stop(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== API Types ====================
|
||||
|
||||
// CreateRequest - request for Create()
|
||||
type CreateRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
Config *types.Config `json:"robot_config"`
|
||||
}
|
||||
|
||||
// UpdateRequest - request for Update()
|
||||
type UpdateRequest struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
Config *types.Config `json:"robot_config,omitempty"`
|
||||
}
|
||||
|
||||
// ListQuery - query options for List()
|
||||
type ListQuery struct {
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
Status types.RobotStatus `json:"status,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ClockMode types.ClockMode `json:"clock_mode,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// ListResult - result of List()
|
||||
type ListResult struct {
|
||||
Data []*types.Robot `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
|
||||
// RobotState - runtime state from Status()
|
||||
type RobotState struct {
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status types.RobotStatus `json:"status"`
|
||||
Running int `json:"running"`
|
||||
MaxRunning int `json:"max_running"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerRequest - request for Trigger()
|
||||
type TriggerRequest struct {
|
||||
Type types.TriggerType `json:"type"` // human | event
|
||||
|
||||
// Human intervention fields (when Type = human)
|
||||
Action types.InterventionAction `json:"action,omitempty"`
|
||||
Messages []interface{} `json:"messages,omitempty"` // context.Message
|
||||
PlanAt *time.Time `json:"plan_at,omitempty"`
|
||||
InsertPosition types.InsertPosition `json:"insert_at,omitempty"`
|
||||
AtIndex int `json:"at_index,omitempty"`
|
||||
|
||||
// Event fields (when Type = event)
|
||||
Source types.EventSource `json:"source,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerResult - result of Trigger()
|
||||
type TriggerResult struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Queued bool `json:"queued"`
|
||||
Execution *types.Execution `json:"execution,omitempty"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionQuery - query options for GetExecutions()
|
||||
type ExecutionQuery struct {
|
||||
Status types.ExecStatus `json:"status,omitempty"`
|
||||
Trigger types.TriggerType `json:"trigger,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionResult - result of GetExecutions()
|
||||
type ExecutionResult struct {
|
||||
Data []*types.Execution `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
493
agent/robot/api/api_test.go
Normal file
493
agent/robot/api/api_test.go
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
package api_test
|
||||
|
||||
// Integration tests for the Robot Agent API
|
||||
// These tests verify the complete API functionality with real database operations.
|
||||
//
|
||||
// Test Structure:
|
||||
// - api_test.go: Core API integration tests (this file)
|
||||
// - lifecycle_test.go: Start/Stop lifecycle tests
|
||||
// - robot_test.go: Robot query tests
|
||||
// - trigger_test.go: Trigger tests
|
||||
// - execution_test.go: Execution query/control tests
|
||||
//
|
||||
// Test Data:
|
||||
// All tests use real database records in __yao.member and agent_execution tables
|
||||
// Test robot IDs are prefixed with "robot_api_" for easy cleanup
|
||||
// Test execution IDs are prefixed with "exec_api_" for easy cleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/xun/capsule"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// ==================== Full Lifecycle Integration Tests ====================
|
||||
|
||||
// TestAPIFullLifecycle tests the complete API workflow:
|
||||
// Start → Create Robot → Query Robot → Trigger → Query Execution → Stop
|
||||
func TestAPIFullLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Cleanup test data
|
||||
cleanupAPITestRobots(t)
|
||||
cleanupAPITestExecutions(t)
|
||||
defer cleanupAPITestRobots(t)
|
||||
defer cleanupAPITestExecutions(t)
|
||||
|
||||
t.Run("complete workflow", func(t *testing.T) {
|
||||
// 1. Setup: Create test robot in database
|
||||
setupAPITestRobot(t, "robot_api_lifecycle_001", "team_api_001")
|
||||
|
||||
// 2. Start the API system
|
||||
err := api.Start()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, api.IsRunning())
|
||||
defer api.Stop()
|
||||
|
||||
// 3. Query the robot via API
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
robot, err := api.GetRobot(ctx, "robot_api_lifecycle_001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, robot)
|
||||
assert.Equal(t, "robot_api_lifecycle_001", robot.MemberID)
|
||||
assert.Equal(t, "team_api_001", robot.TeamID)
|
||||
assert.Equal(t, "API Test Robot robot_api_lifecycle_001", robot.DisplayName)
|
||||
|
||||
// 4. Get robot status
|
||||
status, err := api.GetRobotStatus(ctx, "robot_api_lifecycle_001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, status)
|
||||
assert.Equal(t, "robot_api_lifecycle_001", status.MemberID)
|
||||
assert.Equal(t, types.RobotIdle, status.Status)
|
||||
assert.Equal(t, 0, status.Running)
|
||||
assert.Equal(t, 5, status.MaxRunning)
|
||||
|
||||
// 5. List robots
|
||||
listResult, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_001",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, listResult)
|
||||
assert.GreaterOrEqual(t, listResult.Total, 1)
|
||||
|
||||
// Find our robot in the list
|
||||
found := false
|
||||
for _, r := range listResult.Data {
|
||||
if r.MemberID == "robot_api_lifecycle_001" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Robot should be in list")
|
||||
|
||||
// 6. Trigger manual execution
|
||||
triggerResult, err := api.TriggerManual(ctx, "robot_api_lifecycle_001", types.TriggerClock, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, triggerResult)
|
||||
assert.True(t, triggerResult.Accepted)
|
||||
assert.NotEmpty(t, triggerResult.JobID)
|
||||
|
||||
// 7. Wait for execution to complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 8. Stop the system
|
||||
err = api.Stop()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, api.IsRunning())
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIRobotQueryWithData tests robot query APIs with real data
|
||||
func TestAPIRobotQueryWithData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupAPITestRobots(t)
|
||||
defer cleanupAPITestRobots(t)
|
||||
|
||||
// Setup: Create multiple robots
|
||||
setupAPITestRobot(t, "robot_api_query_001", "team_api_query")
|
||||
setupAPITestRobot(t, "robot_api_query_002", "team_api_query")
|
||||
setupAPITestRobot(t, "robot_api_query_003", "team_api_other")
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("GetRobot returns correct robot", func(t *testing.T) {
|
||||
robot, err := api.GetRobot(ctx, "robot_api_query_001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, robot)
|
||||
|
||||
assert.Equal(t, "robot_api_query_001", robot.MemberID)
|
||||
assert.Equal(t, "team_api_query", robot.TeamID)
|
||||
assert.Equal(t, "API Test Robot robot_api_query_001", robot.DisplayName)
|
||||
assert.True(t, robot.AutonomousMode)
|
||||
assert.Equal(t, types.RobotIdle, robot.Status)
|
||||
})
|
||||
|
||||
t.Run("ListRobots filters by team", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_query",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should have at least 2 robots from team_api_query
|
||||
// (might have more if other tests created robots in this team)
|
||||
assert.GreaterOrEqual(t, result.Total, 2)
|
||||
assert.GreaterOrEqual(t, len(result.Data), 2)
|
||||
|
||||
// Verify all returned robots are from the correct team
|
||||
for _, robot := range result.Data {
|
||||
assert.Equal(t, "team_api_query", robot.TeamID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListRobots pagination works", func(t *testing.T) {
|
||||
// Page 1 with size 1
|
||||
result1, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_query",
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(result1.Data), 1, "Should have at least 1 robot on page 1")
|
||||
|
||||
// Page 2 with size 1
|
||||
result2, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_query",
|
||||
Page: 2,
|
||||
PageSize: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(result2.Data), 1, "Should have at least 1 robot on page 2")
|
||||
|
||||
// Should be different robots
|
||||
assert.NotEqual(t, result1.Data[0].MemberID, result2.Data[0].MemberID)
|
||||
})
|
||||
|
||||
t.Run("ListRobots filters by keywords", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
Keywords: "robot_api_query_001",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should find at least 1 robot matching keywords
|
||||
assert.GreaterOrEqual(t, result.Total, 1)
|
||||
for _, robot := range result.Data {
|
||||
assert.Contains(t, robot.DisplayName, "robot_api_query_001")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIExecutionQueryWithData tests execution query APIs with real data
|
||||
func TestAPIExecutionQueryWithData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupAPITestExecutions(t)
|
||||
defer cleanupAPITestExecutions(t)
|
||||
|
||||
// Setup: Create test executions
|
||||
setupAPITestExecution(t, "exec_api_query_001", "member_api_exec", types.TriggerClock, types.ExecCompleted)
|
||||
setupAPITestExecution(t, "exec_api_query_002", "member_api_exec", types.TriggerHuman, types.ExecRunning)
|
||||
setupAPITestExecution(t, "exec_api_query_003", "member_api_exec", types.TriggerClock, types.ExecFailed)
|
||||
setupAPITestExecution(t, "exec_api_query_004", "member_api_other", types.TriggerEvent, types.ExecCompleted)
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("GetExecution returns correct execution", func(t *testing.T) {
|
||||
exec, err := api.GetExecution(ctx, "exec_api_query_001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec)
|
||||
|
||||
assert.Equal(t, "exec_api_query_001", exec.ID)
|
||||
assert.Equal(t, "member_api_exec", exec.MemberID)
|
||||
assert.Equal(t, types.TriggerClock, exec.TriggerType)
|
||||
assert.Equal(t, types.ExecCompleted, exec.Status)
|
||||
})
|
||||
|
||||
t.Run("ListExecutions filters by member", func(t *testing.T) {
|
||||
result, err := api.ListExecutions(ctx, "member_api_exec", &api.ExecutionQuery{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should have 3 executions for member_api_exec
|
||||
assert.Equal(t, 3, result.Total)
|
||||
assert.Len(t, result.Data, 3)
|
||||
|
||||
// Verify all returned executions are for the correct member
|
||||
for _, exec := range result.Data {
|
||||
assert.Equal(t, "member_api_exec", exec.MemberID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListExecutions filters by status", func(t *testing.T) {
|
||||
result, err := api.ListExecutions(ctx, "member_api_exec", &api.ExecutionQuery{
|
||||
Status: types.ExecCompleted,
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should have only completed executions
|
||||
assert.Equal(t, 1, result.Total)
|
||||
for _, exec := range result.Data {
|
||||
assert.Equal(t, types.ExecCompleted, exec.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListExecutions filters by trigger type", func(t *testing.T) {
|
||||
result, err := api.ListExecutions(ctx, "member_api_exec", &api.ExecutionQuery{
|
||||
Trigger: types.TriggerClock,
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should have only clock trigger executions
|
||||
assert.Equal(t, 2, result.Total)
|
||||
for _, exec := range result.Data {
|
||||
assert.Equal(t, types.TriggerClock, exec.TriggerType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListExecutions pagination works", func(t *testing.T) {
|
||||
// Page 1 with size 2
|
||||
result1, err := api.ListExecutions(ctx, "member_api_exec", &api.ExecutionQuery{
|
||||
Page: 1,
|
||||
PageSize: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(result1.Data), 2, "Should have at least 2 executions on page 1")
|
||||
|
||||
// Page 2 with size 2
|
||||
result2, err := api.ListExecutions(ctx, "member_api_exec", &api.ExecutionQuery{
|
||||
Page: 2,
|
||||
PageSize: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(result2.Data), 1, "Should have at least 1 execution on page 2")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPITriggerWithData tests trigger APIs with real robots
|
||||
func TestAPITriggerWithData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupAPITestRobots(t)
|
||||
defer cleanupAPITestRobots(t)
|
||||
|
||||
// Setup: Create test robot
|
||||
setupAPITestRobot(t, "robot_api_trigger_001", "team_api_trigger")
|
||||
|
||||
// Start manager
|
||||
err := api.Start()
|
||||
require.NoError(t, err)
|
||||
defer api.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("TriggerManual accepts valid robot", func(t *testing.T) {
|
||||
result, err := api.TriggerManual(ctx, "robot_api_trigger_001", types.TriggerClock, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.True(t, result.Accepted)
|
||||
assert.NotEmpty(t, result.JobID)
|
||||
assert.Contains(t, result.Message, "submitted")
|
||||
})
|
||||
|
||||
t.Run("Trigger with human type", func(t *testing.T) {
|
||||
result, err := api.Trigger(ctx, "robot_api_trigger_001", &api.TriggerRequest{
|
||||
Type: types.TriggerHuman,
|
||||
Action: types.ActionTaskAdd,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Result should be returned (accepted or not depends on robot state)
|
||||
// The important thing is that the API doesn't error
|
||||
t.Logf("Trigger result: accepted=%v, message=%s", result.Accepted, result.Message)
|
||||
})
|
||||
|
||||
t.Run("Trigger with event type", func(t *testing.T) {
|
||||
result, err := api.Trigger(ctx, "robot_api_trigger_001", &api.TriggerRequest{
|
||||
Type: types.TriggerEvent,
|
||||
Source: types.EventWebhook,
|
||||
EventType: "test.event",
|
||||
Data: map[string]interface{}{"key": "value"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should be accepted (robot exists and has event enabled)
|
||||
assert.True(t, result.Accepted)
|
||||
})
|
||||
|
||||
t.Run("Trigger rejects non-existent robot", func(t *testing.T) {
|
||||
result, err := api.Trigger(ctx, "robot_api_nonexistent", &api.TriggerRequest{
|
||||
Type: types.TriggerHuman,
|
||||
})
|
||||
// Should not return error, but result should show not accepted
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.False(t, result.Accepted)
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
// setupAPITestRobot creates a test robot in the database
|
||||
func setupAPITestRobot(t *testing.T, memberID, teamID string) {
|
||||
m := model.Select("__yao.member")
|
||||
tableName := m.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
robotConfig := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "API Test Robot",
|
||||
"duties": []string{"Testing API functions"},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 5,
|
||||
"queue": 20,
|
||||
"priority": 5,
|
||||
},
|
||||
"triggers": map[string]interface{}{
|
||||
"clock": map[string]interface{}{"enabled": true},
|
||||
"intervene": map[string]interface{}{"enabled": true},
|
||||
"event": map[string]interface{}{"enabled": true},
|
||||
},
|
||||
"clock": map[string]interface{}{
|
||||
"mode": "interval",
|
||||
"every": "1h",
|
||||
"timeout": "30m",
|
||||
},
|
||||
}
|
||||
configJSON, _ := json.Marshal(robotConfig)
|
||||
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": memberID,
|
||||
"team_id": teamID,
|
||||
"member_type": "robot",
|
||||
"display_name": "API Test Robot " + memberID,
|
||||
"system_prompt": "You are an API test robot.",
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configJSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot %s: %v", memberID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// setupAPITestExecution creates a test execution in the database
|
||||
func setupAPITestExecution(t *testing.T, execID, memberID string, triggerType types.TriggerType, status types.ExecStatus) {
|
||||
s := store.NewExecutionStore()
|
||||
ctx := context.Background()
|
||||
|
||||
startTime := time.Now().Add(-1 * time.Hour)
|
||||
record := &store.ExecutionRecord{
|
||||
ExecutionID: execID,
|
||||
MemberID: memberID,
|
||||
TeamID: "team_api_exec",
|
||||
JobID: "job_" + execID,
|
||||
TriggerType: triggerType,
|
||||
Status: status,
|
||||
Phase: types.PhaseDelivery,
|
||||
StartTime: &startTime,
|
||||
}
|
||||
|
||||
if status == types.ExecCompleted || status == types.ExecFailed {
|
||||
endTime := time.Now()
|
||||
record.EndTime = &endTime
|
||||
}
|
||||
|
||||
err := s.Save(ctx, record)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert execution %s: %v", execID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupAPITestRobots removes all API test robots
|
||||
func cleanupAPITestRobots(t *testing.T) {
|
||||
m := model.Select("__yao.member")
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
tableName := m.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
// Delete all robots with member_id starting with "robot_api_"
|
||||
_, err := qb.Table(tableName).Where("member_id", "like", "robot_api_%").Delete()
|
||||
if err != nil {
|
||||
t.Logf("Warning: cleanup robots error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupAPITestExecutions removes all API test executions
|
||||
func cleanupAPITestExecutions(t *testing.T) {
|
||||
m := model.Select("__yao.agent.execution")
|
||||
if m == nil {
|
||||
t.Logf("Warning: model __yao.agent.execution not found, skipping cleanup")
|
||||
return
|
||||
}
|
||||
tableName := m.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
// Delete all executions with execution_id starting with "exec_api_"
|
||||
_, err := qb.Table(tableName).Where("execution_id", "like", "exec_api_%").Delete()
|
||||
if err != nil {
|
||||
t.Logf("Warning: cleanup executions error: %v", err)
|
||||
}
|
||||
|
||||
// Also delete executions for API test members
|
||||
_, err = qb.Table(tableName).Where("member_id", "like", "member_api_%").Delete()
|
||||
if err != nil {
|
||||
t.Logf("Warning: cleanup executions error: %v", err)
|
||||
}
|
||||
}
|
||||
192
agent/robot/api/execution.go
Normal file
192
agent/robot/api/execution.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// executionStore singleton
|
||||
var (
|
||||
execStore *store.ExecutionStore
|
||||
execStoreOnce sync.Once
|
||||
)
|
||||
|
||||
// getExecutionStore returns the singleton execution store
|
||||
func getExecutionStore() *store.ExecutionStore {
|
||||
execStoreOnce.Do(func() {
|
||||
execStore = store.NewExecutionStore()
|
||||
})
|
||||
return execStore
|
||||
}
|
||||
|
||||
// ResetExecutionStore resets the singleton for testing purposes
|
||||
// This should only be called in tests
|
||||
func ResetExecutionStore() {
|
||||
execStoreOnce = sync.Once{}
|
||||
execStore = nil
|
||||
}
|
||||
|
||||
// ==================== Execution Query API ====================
|
||||
// These functions query and manage execution history
|
||||
|
||||
// GetExecution returns a specific execution by ID
|
||||
func GetExecution(ctx *types.Context, execID string) (*types.Execution, error) {
|
||||
if execID == "" {
|
||||
return nil, fmt.Errorf("execution_id is required")
|
||||
}
|
||||
|
||||
// Try to get from execution store
|
||||
record, err := getExecutionStore().Get(context.Background(), execID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get execution: %w", err)
|
||||
}
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("execution not found: %s", execID)
|
||||
}
|
||||
|
||||
return record.ToExecution(), nil
|
||||
}
|
||||
|
||||
// ListExecutions returns execution history for a robot
|
||||
func ListExecutions(ctx *types.Context, memberID string, query *ExecutionQuery) (*ExecutionResult, error) {
|
||||
if memberID == "" {
|
||||
return nil, fmt.Errorf("member_id is required")
|
||||
}
|
||||
|
||||
if query == nil {
|
||||
query = &ExecutionQuery{}
|
||||
}
|
||||
query.applyDefaults()
|
||||
|
||||
// Build list options
|
||||
opts := &store.ListOptions{
|
||||
MemberID: memberID,
|
||||
Limit: query.PageSize,
|
||||
Offset: (query.Page - 1) * query.PageSize,
|
||||
OrderBy: "start_time desc",
|
||||
}
|
||||
|
||||
if query.Status != "" {
|
||||
opts.Status = query.Status
|
||||
}
|
||||
if query.Trigger != "" {
|
||||
opts.TriggerType = query.Trigger
|
||||
}
|
||||
|
||||
// Query from store
|
||||
records, err := getExecutionStore().List(context.Background(), opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list executions: %w", err)
|
||||
}
|
||||
|
||||
// Convert to Execution slice
|
||||
executions := make([]*types.Execution, 0, len(records))
|
||||
for _, record := range records {
|
||||
executions = append(executions, record.ToExecution())
|
||||
}
|
||||
|
||||
// Get total count
|
||||
// Note: For accurate total, ExecutionStore.List should return total count
|
||||
// Current implementation returns estimated total based on returned records
|
||||
total := len(records)
|
||||
if total >= query.PageSize {
|
||||
// Has more records, need to query total count
|
||||
// For now, indicate there might be more by setting total to -1
|
||||
// UI should handle this as "has more"
|
||||
countOpts := &store.ListOptions{MemberID: memberID}
|
||||
if query.Status != "" {
|
||||
countOpts.Status = query.Status
|
||||
}
|
||||
if query.Trigger != "" {
|
||||
countOpts.TriggerType = query.Trigger
|
||||
}
|
||||
allRecords, _ := getExecutionStore().List(context.Background(), countOpts)
|
||||
total = len(allRecords)
|
||||
}
|
||||
|
||||
return &ExecutionResult{
|
||||
Data: executions,
|
||||
Total: total,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ==================== Execution Control API ====================
|
||||
// These functions control running executions
|
||||
|
||||
// PauseExecution pauses a running execution
|
||||
func PauseExecution(ctx *types.Context, execID string) error {
|
||||
if execID == "" {
|
||||
return fmt.Errorf("execution_id is required")
|
||||
}
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mgr.PauseExecution(ctx, execID)
|
||||
}
|
||||
|
||||
// ResumeExecution resumes a paused execution
|
||||
func ResumeExecution(ctx *types.Context, execID string) error {
|
||||
if execID == "" {
|
||||
return fmt.Errorf("execution_id is required")
|
||||
}
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mgr.ResumeExecution(ctx, execID)
|
||||
}
|
||||
|
||||
// StopExecution stops a running execution
|
||||
func StopExecution(ctx *types.Context, execID string) error {
|
||||
if execID == "" {
|
||||
return fmt.Errorf("execution_id is required")
|
||||
}
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mgr.StopExecution(ctx, execID)
|
||||
}
|
||||
|
||||
// ==================== Execution Status API ====================
|
||||
|
||||
// GetExecutionStatus returns the current status of an execution
|
||||
// This combines stored data with runtime state
|
||||
func GetExecutionStatus(ctx *types.Context, execID string) (*types.Execution, error) {
|
||||
if execID == "" {
|
||||
return nil, fmt.Errorf("execution_id is required")
|
||||
}
|
||||
|
||||
// Get from store first
|
||||
exec, err := GetExecution(ctx, execID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If manager is running, check for runtime state
|
||||
mgr, mgrErr := getManager()
|
||||
if mgrErr == nil {
|
||||
// Check if execution is being tracked (running)
|
||||
ctrlExec, ctrlErr := mgr.GetExecutionStatus(execID)
|
||||
if ctrlErr == nil && ctrlExec != nil {
|
||||
// Update with runtime state
|
||||
exec.Status = ctrlExec.Status
|
||||
exec.Phase = ctrlExec.Phase
|
||||
}
|
||||
}
|
||||
|
||||
return exec, nil
|
||||
}
|
||||
202
agent/robot/api/execution_test.go
Normal file
202
agent/robot/api/execution_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestGetExecutionValidation tests parameter validation for GetExecution
|
||||
func TestGetExecutionValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty execution_id", func(t *testing.T) {
|
||||
exec, err := api.GetExecution(ctx, "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, exec)
|
||||
assert.Contains(t, err.Error(), "execution_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error for non-existent execution", func(t *testing.T) {
|
||||
exec, err := api.GetExecution(ctx, "non_existent_exec_id_xyz")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, exec)
|
||||
})
|
||||
}
|
||||
|
||||
// TestListExecutionsValidation tests parameter validation for ListExecutions
|
||||
func TestListExecutionsValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty member_id", func(t *testing.T) {
|
||||
result, err := api.ListExecutions(ctx, "", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("applies default pagination when query is nil", func(t *testing.T) {
|
||||
result, err := api.ListExecutions(ctx, "test_member", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 1, result.Page)
|
||||
assert.Equal(t, 20, result.PageSize)
|
||||
})
|
||||
|
||||
t.Run("caps pagesize at 100", func(t *testing.T) {
|
||||
result, err := api.ListExecutions(ctx, "test_member", &api.ExecutionQuery{
|
||||
Page: 1,
|
||||
PageSize: 200,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 100, result.PageSize)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPauseExecutionValidation tests parameter validation for PauseExecution
|
||||
func TestPauseExecutionValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty execution_id", func(t *testing.T) {
|
||||
err := api.PauseExecution(ctx, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "execution_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error when manager not started", func(t *testing.T) {
|
||||
err := api.PauseExecution(ctx, "test_exec_id")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
})
|
||||
}
|
||||
|
||||
// TestResumeExecutionValidation tests parameter validation for ResumeExecution
|
||||
func TestResumeExecutionValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty execution_id", func(t *testing.T) {
|
||||
err := api.ResumeExecution(ctx, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "execution_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error when manager not started", func(t *testing.T) {
|
||||
err := api.ResumeExecution(ctx, "test_exec_id")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStopExecutionValidation tests parameter validation for StopExecution
|
||||
func TestStopExecutionValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty execution_id", func(t *testing.T) {
|
||||
err := api.StopExecution(ctx, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "execution_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error when manager not started", func(t *testing.T) {
|
||||
err := api.StopExecution(ctx, "test_exec_id")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetExecutionStatusValidation tests parameter validation for GetExecutionStatus
|
||||
func TestGetExecutionStatusValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty execution_id", func(t *testing.T) {
|
||||
exec, err := api.GetExecutionStatus(ctx, "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, exec)
|
||||
assert.Contains(t, err.Error(), "execution_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error for non-existent execution", func(t *testing.T) {
|
||||
exec, err := api.GetExecutionStatus(ctx, "non_existent_exec_id_xyz")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, exec)
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecutionControlWithManagerStarted tests execution control APIs when manager is running
|
||||
func TestExecutionControlWithManagerStarted(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Start manager
|
||||
err := api.Start()
|
||||
require.NoError(t, err)
|
||||
defer api.Stop()
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("pause returns error for non-existent execution", func(t *testing.T) {
|
||||
err := api.PauseExecution(ctx, "non_existent_exec_id_xyz")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("resume returns error for non-existent execution", func(t *testing.T) {
|
||||
err := api.ResumeExecution(ctx, "non_existent_exec_id_xyz")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("stop returns error for non-existent execution", func(t *testing.T) {
|
||||
err := api.StopExecution(ctx, "non_existent_exec_id_xyz")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package api
|
||||
|
||||
// JSAPI for V8 Runtime
|
||||
// Stub: all JSAPI methods return errors (will be implemented in Phase 10)
|
||||
|
||||
// This file defines the JavaScript API structure
|
||||
// Implementation will be added in Phase 10
|
||||
|
||||
// ExportFunction exports the Robot constructor to V8
|
||||
// Stub: not implemented yet (will be implemented in Phase 10)
|
||||
|
||||
// ExportObject exports the robot global object to V8
|
||||
// Stub: not implemented yet (will be implemented in Phase 10)
|
||||
|
||||
// The actual V8 integration will be implemented in Phase 10 following the pattern:
|
||||
// - Robot constructor: new Robot(memberID)
|
||||
// - Global robot object: robot.List(), robot.Get(), etc.
|
||||
// - Instance methods: bot.Status(), bot.Trigger(), etc.
|
||||
101
agent/robot/api/lifecycle.go
Normal file
101
agent/robot/api/lifecycle.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/manager"
|
||||
)
|
||||
|
||||
// ==================== Lifecycle API ====================
|
||||
// These functions manage the robot agent system lifecycle
|
||||
|
||||
var (
|
||||
globalManager *manager.Manager
|
||||
managerMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Start starts the robot agent system
|
||||
// This initializes and starts the manager which handles:
|
||||
// - Robot cache loading
|
||||
// - Worker pool
|
||||
// - Clock ticker for scheduled triggers
|
||||
func Start() error {
|
||||
managerMu.Lock()
|
||||
defer managerMu.Unlock()
|
||||
|
||||
if globalManager != nil && globalManager.IsStarted() {
|
||||
return fmt.Errorf("robot agent system already started")
|
||||
}
|
||||
|
||||
// Create new manager if not exists
|
||||
if globalManager == nil {
|
||||
globalManager = manager.New()
|
||||
}
|
||||
|
||||
return globalManager.Start()
|
||||
}
|
||||
|
||||
// StartWithConfig starts the robot agent system with custom configuration
|
||||
func StartWithConfig(config *manager.Config) error {
|
||||
managerMu.Lock()
|
||||
defer managerMu.Unlock()
|
||||
|
||||
if globalManager != nil && globalManager.IsStarted() {
|
||||
return fmt.Errorf("robot agent system already started")
|
||||
}
|
||||
|
||||
globalManager = manager.NewWithConfig(config)
|
||||
return globalManager.Start()
|
||||
}
|
||||
|
||||
// Stop stops the robot agent system gracefully
|
||||
// This will:
|
||||
// - Stop the clock ticker
|
||||
// - Stop cache auto-refresh
|
||||
// - Wait for running jobs to complete
|
||||
// - Stop the worker pool
|
||||
func Stop() error {
|
||||
managerMu.Lock()
|
||||
defer managerMu.Unlock()
|
||||
|
||||
if globalManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := globalManager.Stop()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset global manager
|
||||
globalManager = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns true if the robot agent system is running
|
||||
func IsRunning() bool {
|
||||
managerMu.RLock()
|
||||
defer managerMu.RUnlock()
|
||||
|
||||
return globalManager != nil && globalManager.IsStarted()
|
||||
}
|
||||
|
||||
// getManager returns the global manager instance
|
||||
// Returns error if manager is not started
|
||||
func getManager() (*manager.Manager, error) {
|
||||
managerMu.RLock()
|
||||
defer managerMu.RUnlock()
|
||||
|
||||
if globalManager == nil || !globalManager.IsStarted() {
|
||||
return nil, fmt.Errorf("robot agent system not started")
|
||||
}
|
||||
return globalManager, nil
|
||||
}
|
||||
|
||||
// SetManager sets the global manager instance (for testing)
|
||||
func SetManager(m *manager.Manager) {
|
||||
managerMu.Lock()
|
||||
defer managerMu.Unlock()
|
||||
globalManager = m
|
||||
}
|
||||
64
agent/robot/api/lifecycle_test.go
Normal file
64
agent/robot/api/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestLifecycle tests the Start/Stop lifecycle APIs
|
||||
func TestLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("start and stop cycle", func(t *testing.T) {
|
||||
// Initially not running
|
||||
assert.False(t, api.IsRunning())
|
||||
|
||||
// Start
|
||||
err := api.Start()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, api.IsRunning())
|
||||
|
||||
// Start again should fail
|
||||
err = api.Start()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already started")
|
||||
|
||||
// Stop
|
||||
err = api.Stop()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, api.IsRunning())
|
||||
|
||||
// Stop again should be no-op (not error)
|
||||
err = api.Stop()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("can restart after stop", func(t *testing.T) {
|
||||
// Start
|
||||
err := api.Start()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, api.IsRunning())
|
||||
|
||||
// Stop
|
||||
err = api.Stop()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, api.IsRunning())
|
||||
|
||||
// Start again should work
|
||||
err = api.Start()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, api.IsRunning())
|
||||
|
||||
// Cleanup
|
||||
api.Stop()
|
||||
})
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
package api
|
||||
|
||||
// Process API for Yao Process system
|
||||
// Stub: all process handlers return errors (will be implemented in Phase 10)
|
||||
|
||||
// processGet handles robot.Get process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processGet(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processList handles robot.List process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processList(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processCreate handles robot.Create process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processCreate(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processUpdate handles robot.Update process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processUpdate(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processRemove handles robot.Remove process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processRemove(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processStatus handles robot.Status process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processStatus(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processUpdateStatus handles robot.UpdateStatus process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processUpdateStatus(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processTrigger handles robot.Trigger process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processTrigger(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processExecutions handles robot.Executions process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processExecutions(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processExecution handles robot.Execution process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processExecution(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processPause handles robot.Pause process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processPause(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processResume handles robot.Resume process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processResume(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processStop handles robot.Stop process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processStop(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
258
agent/robot/api/robot.go
Normal file
258
agent/robot/api/robot.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ==================== Robot Query API ====================
|
||||
// These functions query robot information
|
||||
|
||||
// memberModel is the model name for member table
|
||||
const memberModel = "__yao.member"
|
||||
|
||||
// GetRobot returns a robot by member ID
|
||||
// Returns the robot from cache if available, otherwise loads from database
|
||||
func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) {
|
||||
if memberID == "" {
|
||||
return nil, fmt.Errorf("member_id is required")
|
||||
}
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
// Manager not started, try to load directly from database
|
||||
return loadRobotFromDB(memberID)
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
robot := mgr.Cache().Get(memberID)
|
||||
if robot != nil {
|
||||
return robot, nil
|
||||
}
|
||||
|
||||
// Not in cache, try to load from database
|
||||
robot, err = mgr.Cache().LoadByID(ctx, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return robot, nil
|
||||
}
|
||||
|
||||
// ListRobots returns robots with pagination and filtering
|
||||
func ListRobots(ctx *types.Context, query *ListQuery) (*ListResult, error) {
|
||||
if query == nil {
|
||||
query = &ListQuery{}
|
||||
}
|
||||
query.applyDefaults()
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
// Manager not started, load directly from database
|
||||
return listRobotsFromDB(query)
|
||||
}
|
||||
|
||||
// If only teamID specified (no other filters), use cache for faster lookup
|
||||
// Note: Cache only contains autonomous_mode=true robots, so this is safe
|
||||
if query.TeamID != "" && query.Status == "" && query.Keywords == "" && query.ClockMode == "" {
|
||||
robots := mgr.Cache().List(query.TeamID)
|
||||
return paginateRobots(robots, query), nil
|
||||
}
|
||||
|
||||
// For complex queries, load from database
|
||||
return listRobotsFromDB(query)
|
||||
}
|
||||
|
||||
// GetRobotStatus returns the runtime status of a robot
|
||||
func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
|
||||
if memberID == "" {
|
||||
return nil, fmt.Errorf("member_id is required")
|
||||
}
|
||||
|
||||
robot, err := GetRobot(ctx, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := &RobotState{
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
DisplayName: robot.DisplayName,
|
||||
Status: robot.Status,
|
||||
Running: robot.RunningCount(),
|
||||
MaxRunning: 2, // default
|
||||
}
|
||||
|
||||
if robot.Config != nil && robot.Config.Quota != nil {
|
||||
state.MaxRunning = robot.Config.Quota.GetMax()
|
||||
}
|
||||
|
||||
// Get running execution IDs
|
||||
executions := robot.GetExecutions()
|
||||
state.RunningIDs = make([]string, 0, len(executions))
|
||||
for _, exec := range executions {
|
||||
state.RunningIDs = append(state.RunningIDs, exec.ID)
|
||||
}
|
||||
|
||||
// Set last run time
|
||||
if !robot.LastRun.IsZero() {
|
||||
state.LastRun = &robot.LastRun
|
||||
}
|
||||
|
||||
// Set next run time
|
||||
if !robot.NextRun.IsZero() {
|
||||
state.NextRun = &robot.NextRun
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
// loadRobotFromDB loads a robot directly from database
|
||||
func loadRobotFromDB(memberID string) (*types.Robot, error) {
|
||||
m := model.Select(memberModel)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("model %s not found", memberModel)
|
||||
}
|
||||
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{
|
||||
"id", "member_id", "team_id", "display_name",
|
||||
"system_prompt", "robot_status", "autonomous_mode",
|
||||
"robot_config", "robot_email",
|
||||
},
|
||||
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: %w", err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
return types.NewRobotFromMap(map[string]interface{}(records[0]))
|
||||
}
|
||||
|
||||
// listRobotsFromDB loads robots from database with filtering
|
||||
func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
|
||||
m := model.Select(memberModel)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("model %s not found", memberModel)
|
||||
}
|
||||
|
||||
// Build where conditions
|
||||
wheres := []model.QueryWhere{
|
||||
{Column: "member_type", Value: "robot"},
|
||||
{Column: "autonomous_mode", Value: true},
|
||||
{Column: "status", Value: "active"},
|
||||
}
|
||||
|
||||
if query.TeamID != "" {
|
||||
wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: query.TeamID})
|
||||
}
|
||||
if query.Status != "" {
|
||||
wheres = append(wheres, model.QueryWhere{Column: "robot_status", Value: string(query.Status)})
|
||||
}
|
||||
if query.Keywords != "" {
|
||||
wheres = append(wheres, model.QueryWhere{
|
||||
Column: "display_name",
|
||||
OP: "like",
|
||||
Value: "%" + query.Keywords + "%",
|
||||
})
|
||||
}
|
||||
|
||||
// Build order
|
||||
orders := []model.QueryOrder{}
|
||||
if query.Order != "" {
|
||||
orders = append(orders, model.QueryOrder{Column: query.Order})
|
||||
} else {
|
||||
orders = append(orders, model.QueryOrder{Column: "created_at", Option: "desc"})
|
||||
}
|
||||
|
||||
// Execute paginated query
|
||||
result, err := m.Paginate(model.QueryParam{
|
||||
Select: []interface{}{
|
||||
"id", "member_id", "team_id", "display_name",
|
||||
"system_prompt", "robot_status", "autonomous_mode",
|
||||
"robot_config", "robot_email",
|
||||
},
|
||||
Wheres: wheres,
|
||||
Orders: orders,
|
||||
}, query.Page, query.PageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list robots: %w", err)
|
||||
}
|
||||
|
||||
// Parse result
|
||||
listResult := &ListResult{
|
||||
Data: []*types.Robot{},
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if total, ok := result.Get("total").(int); ok {
|
||||
listResult.Total = total
|
||||
}
|
||||
|
||||
// Parse robot records - handle both []maps.MapStr and []map[string]interface{}
|
||||
data := result.Get("data")
|
||||
switch records := data.(type) {
|
||||
case []maps.MapStr:
|
||||
for _, record := range records {
|
||||
robot, err := types.NewRobotFromMap(map[string]interface{}(record))
|
||||
if err != nil {
|
||||
continue // skip invalid records
|
||||
}
|
||||
listResult.Data = append(listResult.Data, robot)
|
||||
}
|
||||
case []map[string]interface{}:
|
||||
for _, record := range records {
|
||||
robot, err := types.NewRobotFromMap(record)
|
||||
if err != nil {
|
||||
continue // skip invalid records
|
||||
}
|
||||
listResult.Data = append(listResult.Data, robot)
|
||||
}
|
||||
}
|
||||
|
||||
return listResult, nil
|
||||
}
|
||||
|
||||
// paginateRobots applies pagination to a slice of robots
|
||||
func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult {
|
||||
total := len(robots)
|
||||
|
||||
// Calculate offset
|
||||
offset := (query.Page - 1) * query.PageSize
|
||||
if offset >= total {
|
||||
return &ListResult{
|
||||
Data: []*types.Robot{},
|
||||
Total: total,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate end index
|
||||
end := offset + query.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
return &ListResult{
|
||||
Data: robots[offset:end],
|
||||
Total: total,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
}
|
||||
}
|
||||
101
agent/robot/api/robot_test.go
Normal file
101
agent/robot/api/robot_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestGetRobotValidation tests parameter validation for GetRobot
|
||||
func TestGetRobotValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("returns error for empty member_id", func(t *testing.T) {
|
||||
ctx := types.NewContext(nil, nil)
|
||||
robot, err := api.GetRobot(ctx, "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, robot)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error for non-existent robot", func(t *testing.T) {
|
||||
ctx := types.NewContext(nil, nil)
|
||||
robot, err := api.GetRobot(ctx, "non_existent_member_id_xyz")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, robot)
|
||||
})
|
||||
}
|
||||
|
||||
// TestListRobotsValidation tests parameter validation for ListRobots
|
||||
func TestListRobotsValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("applies default pagination when query is nil", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 1, result.Page)
|
||||
assert.Equal(t, 20, result.PageSize)
|
||||
})
|
||||
|
||||
t.Run("applies default pagination when values are zero", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
Page: 0,
|
||||
PageSize: 0,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 1, result.Page)
|
||||
assert.Equal(t, 20, result.PageSize)
|
||||
})
|
||||
|
||||
t.Run("caps pagesize at 100", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
Page: 1,
|
||||
PageSize: 500,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 100, result.PageSize)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetRobotStatusValidation tests parameter validation for GetRobotStatus
|
||||
func TestGetRobotStatusValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("returns error for empty member_id", func(t *testing.T) {
|
||||
ctx := types.NewContext(nil, nil)
|
||||
status, err := api.GetRobotStatus(ctx, "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, status)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error for non-existent robot", func(t *testing.T) {
|
||||
ctx := types.NewContext(nil, nil)
|
||||
status, err := api.GetRobotStatus(ctx, "non_existent_member_id_xyz")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, status)
|
||||
})
|
||||
}
|
||||
191
agent/robot/api/trigger.go
Normal file
191
agent/robot/api/trigger.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ==================== Trigger API ====================
|
||||
// These functions handle robot execution triggers
|
||||
|
||||
// Trigger starts a robot execution with the specified trigger type and request
|
||||
// This is the main entry point for triggering robot execution
|
||||
func Trigger(ctx *types.Context, memberID string, req *TriggerRequest) (*TriggerResult, error) {
|
||||
if memberID == "" {
|
||||
return nil, fmt.Errorf("member_id is required")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("trigger request is required")
|
||||
}
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case types.TriggerHuman:
|
||||
return triggerHuman(ctx, mgr, memberID, req)
|
||||
case types.TriggerEvent:
|
||||
return triggerEvent(ctx, mgr, memberID, req)
|
||||
case types.TriggerClock:
|
||||
return triggerManual(ctx, mgr, memberID, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid trigger type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerManual manually triggers a robot execution (for testing or debugging)
|
||||
// This bypasses normal trigger validation and directly submits to the pool
|
||||
func TriggerManual(ctx *types.Context, memberID string, triggerType types.TriggerType, data interface{}) (*TriggerResult, error) {
|
||||
if memberID == "" {
|
||||
return nil, fmt.Errorf("member_id is required")
|
||||
}
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
execID, err := mgr.TriggerManual(ctx, memberID, triggerType, data)
|
||||
if err != nil {
|
||||
return &TriggerResult{
|
||||
Accepted: false,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &TriggerResult{
|
||||
Accepted: true,
|
||||
JobID: execID,
|
||||
Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Intervene processes a human intervention request
|
||||
// Human intervention skips P0 (inspiration) and goes directly to P1 (goals)
|
||||
func Intervene(ctx *types.Context, memberID string, req *TriggerRequest) (*TriggerResult, error) {
|
||||
if memberID == "" {
|
||||
return nil, fmt.Errorf("member_id is required")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("intervention request is required")
|
||||
}
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return triggerHuman(ctx, mgr, memberID, req)
|
||||
}
|
||||
|
||||
// HandleEvent processes an event trigger request
|
||||
// Event trigger skips P0 (inspiration) and goes directly to P1 (goals)
|
||||
func HandleEvent(ctx *types.Context, memberID string, req *TriggerRequest) (*TriggerResult, error) {
|
||||
if memberID == "" {
|
||||
return nil, fmt.Errorf("member_id is required")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("event request is required")
|
||||
}
|
||||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return triggerEvent(ctx, mgr, memberID, req)
|
||||
}
|
||||
|
||||
// ==================== Internal Trigger Functions ====================
|
||||
|
||||
// triggerHuman handles human intervention trigger
|
||||
func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req *TriggerRequest) (*TriggerResult, error) {
|
||||
// Build intervention request
|
||||
interveneReq := &types.InterveneRequest{
|
||||
MemberID: memberID,
|
||||
TeamID: ctx.TeamID(),
|
||||
Action: req.Action,
|
||||
Messages: req.Messages,
|
||||
PlanTime: req.PlanAt,
|
||||
ExecutorMode: req.ExecutorMode,
|
||||
}
|
||||
|
||||
// Call manager's Intervene
|
||||
result, err := mgr.Intervene(ctx, interveneReq)
|
||||
if err != nil {
|
||||
return &TriggerResult{
|
||||
Accepted: false,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &TriggerResult{
|
||||
Accepted: true,
|
||||
JobID: result.ExecutionID,
|
||||
Message: result.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// triggerEvent handles event trigger
|
||||
func triggerEvent(ctx *types.Context, mgr managerInterface, memberID string, req *TriggerRequest) (*TriggerResult, error) {
|
||||
// Build event request
|
||||
eventReq := &types.EventRequest{
|
||||
MemberID: memberID,
|
||||
Source: string(req.Source),
|
||||
EventType: req.EventType,
|
||||
Data: req.Data,
|
||||
ExecutorMode: req.ExecutorMode,
|
||||
}
|
||||
|
||||
// Call manager's HandleEvent
|
||||
result, err := mgr.HandleEvent(ctx, eventReq)
|
||||
if err != nil {
|
||||
return &TriggerResult{
|
||||
Accepted: false,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &TriggerResult{
|
||||
Accepted: true,
|
||||
JobID: result.ExecutionID,
|
||||
Message: result.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// triggerManual handles manual/clock trigger
|
||||
func triggerManual(ctx *types.Context, mgr managerInterface, memberID string, req *TriggerRequest) (*TriggerResult, error) {
|
||||
// For clock trigger, pass clock context if available
|
||||
var data interface{}
|
||||
if req.Data != nil {
|
||||
data = req.Data
|
||||
}
|
||||
|
||||
execID, err := mgr.TriggerManual(ctx, memberID, req.Type, data)
|
||||
if err != nil {
|
||||
return &TriggerResult{
|
||||
Accepted: false,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &TriggerResult{
|
||||
Accepted: true,
|
||||
JobID: execID,
|
||||
Message: fmt.Sprintf("Trigger (%s) submitted", req.Type),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// managerInterface defines the methods we need from manager
|
||||
// This allows for easier testing with mocks
|
||||
type managerInterface interface {
|
||||
TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error)
|
||||
Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error)
|
||||
HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error)
|
||||
PauseExecution(ctx *types.Context, execID string) error
|
||||
ResumeExecution(ctx *types.Context, execID string) error
|
||||
StopExecution(ctx *types.Context, execID string) error
|
||||
}
|
||||
170
agent/robot/api/trigger_test.go
Normal file
170
agent/robot/api/trigger_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestTriggerValidation tests parameter validation for Trigger
|
||||
func TestTriggerValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty member_id", func(t *testing.T) {
|
||||
result, err := api.Trigger(ctx, "", &api.TriggerRequest{
|
||||
Type: types.TriggerHuman,
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error for nil request", func(t *testing.T) {
|
||||
result, err := api.Trigger(ctx, "test_member", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "trigger request is required")
|
||||
})
|
||||
|
||||
t.Run("returns error when manager not started", func(t *testing.T) {
|
||||
result, err := api.Trigger(ctx, "test_member", &api.TriggerRequest{
|
||||
Type: types.TriggerHuman,
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
})
|
||||
}
|
||||
|
||||
// TestTriggerManualValidation tests parameter validation for TriggerManual
|
||||
func TestTriggerManualValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty member_id", func(t *testing.T) {
|
||||
result, err := api.TriggerManual(ctx, "", types.TriggerClock, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error when manager not started", func(t *testing.T) {
|
||||
result, err := api.TriggerManual(ctx, "test_member", types.TriggerClock, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
})
|
||||
}
|
||||
|
||||
// TestInterveneValidation tests parameter validation for Intervene
|
||||
func TestInterveneValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty member_id", func(t *testing.T) {
|
||||
result, err := api.Intervene(ctx, "", &api.TriggerRequest{
|
||||
Type: types.TriggerHuman,
|
||||
Action: types.ActionTaskAdd,
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error for nil request", func(t *testing.T) {
|
||||
result, err := api.Intervene(ctx, "test_member", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "intervention request is required")
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleEventValidation tests parameter validation for HandleEvent
|
||||
func TestHandleEventValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns error for empty member_id", func(t *testing.T) {
|
||||
result, err := api.HandleEvent(ctx, "", &api.TriggerRequest{
|
||||
Type: types.TriggerEvent,
|
||||
Source: types.EventWebhook,
|
||||
EventType: "test.event",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns error for nil request", func(t *testing.T) {
|
||||
result, err := api.HandleEvent(ctx, "test_member", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "event request is required")
|
||||
})
|
||||
}
|
||||
|
||||
// TestTriggerWithManagerStarted tests trigger APIs when manager is running
|
||||
func TestTriggerWithManagerStarted(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Start manager
|
||||
err := api.Start()
|
||||
require.NoError(t, err)
|
||||
defer api.Stop()
|
||||
|
||||
ctx := types.NewContext(nil, nil)
|
||||
|
||||
t.Run("returns not accepted for non-existent robot", func(t *testing.T) {
|
||||
result, err := api.Trigger(ctx, "non_existent_robot_xyz", &api.TriggerRequest{
|
||||
Type: types.TriggerHuman,
|
||||
Action: types.ActionTaskAdd,
|
||||
})
|
||||
// Should not error, but return not accepted
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.False(t, result.Accepted)
|
||||
})
|
||||
|
||||
t.Run("returns error for invalid trigger type", func(t *testing.T) {
|
||||
result, err := api.Trigger(ctx, "test_member", &api.TriggerRequest{
|
||||
Type: "invalid_type",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "invalid trigger type")
|
||||
})
|
||||
}
|
||||
128
agent/robot/api/types.go
Normal file
128
agent/robot/api/types.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ListQuery - query options for List()
|
||||
type ListQuery struct {
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
Status types.RobotStatus `json:"status,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ClockMode types.ClockMode `json:"clock_mode,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// ListResult - result of List()
|
||||
type ListResult struct {
|
||||
Data []*types.Robot `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
|
||||
// RobotState - runtime state from Status()
|
||||
type RobotState struct {
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status types.RobotStatus `json:"status"`
|
||||
Running int `json:"running"`
|
||||
MaxRunning int `json:"max_running"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Trigger Types ====================
|
||||
|
||||
// TriggerRequest - request for Trigger()
|
||||
// Input uses []context.Message to support rich content (text, images, files, audio)
|
||||
type TriggerRequest struct {
|
||||
Type types.TriggerType `json:"type"` // human | event | clock
|
||||
|
||||
// Human intervention fields (when Type = human)
|
||||
Action types.InterventionAction `json:"action,omitempty"`
|
||||
Messages []agentcontext.Message `json:"messages,omitempty"` // user's input (supports text, images, files)
|
||||
PlanAt *time.Time `json:"plan_at,omitempty"`
|
||||
InsertPosition InsertPosition `json:"insert_at,omitempty"`
|
||||
AtIndex int `json:"at_index,omitempty"`
|
||||
|
||||
// Event fields (when Type = event)
|
||||
Source types.EventSource `json:"source,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
|
||||
// Executor mode (optional, overrides robot config)
|
||||
ExecutorMode types.ExecutorMode `json:"executor_mode,omitempty"`
|
||||
}
|
||||
|
||||
// InsertPosition - where to insert task in queue
|
||||
type InsertPosition string
|
||||
|
||||
const (
|
||||
InsertFirst InsertPosition = "first" // insert at beginning (highest priority)
|
||||
InsertLast InsertPosition = "last" // append at end (default)
|
||||
InsertNext InsertPosition = "next" // insert after current task
|
||||
InsertAt InsertPosition = "at" // insert at specific index (use AtIndex)
|
||||
)
|
||||
|
||||
// TriggerResult - result of Trigger()
|
||||
type TriggerResult struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Queued bool `json:"queued"`
|
||||
Execution *types.Execution `json:"execution,omitempty"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Execution Types ====================
|
||||
|
||||
// ExecutionQuery - query options for GetExecutions()
|
||||
type ExecutionQuery struct {
|
||||
Status types.ExecStatus `json:"status,omitempty"`
|
||||
Trigger types.TriggerType `json:"trigger,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionResult - result of GetExecutions()
|
||||
type ExecutionResult struct {
|
||||
Data []*types.Execution `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
// applyDefaults applies default values to ListQuery
|
||||
func (q *ListQuery) applyDefaults() {
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
|
||||
// applyDefaults applies default values to ExecutionQuery
|
||||
func (q *ExecutionQuery) applyDefaults() {
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue