Refactor API Structure and Update TODO for MVP Integration

- Updated the API documentation in TODO.md to reflect the completion of the Go API implementation and end-to-end tests for the main flow (P0 → P1 → P2 → P3 → P4).
- Clarified that Process handlers and JSAPI are optional features, moving them to Phase 12, while emphasizing that the Go API is sufficient for MVP integration.
- Removed obsolete API files (`api.go`, `jsapi.go`, `process.go`) as they were stubs and not implemented in Phase 10, streamlining the codebase.
- Revised the structure of the TODO.md to better outline the goals and tasks for Phases 11 and 12, ensuring clear tracking of progress and dependencies.
This commit is contained in:
Max 2026-01-19 09:17:33 +08:00
parent 62a18f0c8d
commit c6ca68876d
15 changed files with 2145 additions and 320 deletions

View file

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

View file

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

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

View 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)
})
}

View file

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

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

View 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()
})
}

View file

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

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

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