Merge pull request #1426 from trheyi/main

Implement Autonomous Agent API
This commit is contained in:
Max 2026-01-22 19:23:14 +08:00 committed by GitHub
commit ef65d4a2bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 8669 additions and 3521 deletions

View file

@ -24,6 +24,7 @@ var systemAgents = []string{
"querydsl",
"title",
"prompt",
"robot_prompt",
"needsearch",
"entity",
}
@ -31,13 +32,14 @@ var systemAgents = []string{
// SystemConfig holds the system agents connector configuration
// This is set from agent.yml system block
type SystemConfig struct {
Default string // Default connector for all system agents
Keyword string // Connector for __yao.keyword agent
QueryDSL string // Connector for __yao.querydsl agent
Title string // Connector for __yao.title agent
Prompt string // Connector for __yao.prompt agent
NeedSearch string // Connector for __yao.needsearch agent
Entity string // Connector for __yao.entity agent
Default string // Default connector for all system agents
Keyword string // Connector for __yao.keyword agent
QueryDSL string // Connector for __yao.querydsl agent
Title string // Connector for __yao.title agent
Prompt string // Connector for __yao.prompt agent
RobotPrompt string // Connector for __yao.robot_prompt agent
NeedSearch string // Connector for __yao.needsearch agent
Entity string // Connector for __yao.entity agent
}
// systemConfig holds the system agents configuration (global variable like others in load.go)
@ -223,6 +225,10 @@ func resolveSystemConnector(agentID string) string {
if systemConfig.Prompt != "" {
return systemConfig.Prompt
}
case "__yao.robot_prompt":
if systemConfig.RobotPrompt != "" {
return systemConfig.RobotPrompt
}
case "__yao.needsearch":
if systemConfig.NeedSearch != "" {
return systemConfig.NeedSearch

View file

@ -52,14 +52,19 @@ func Load(cfg config.Config) error {
setting.Uses = &types.Uses{Default: "mohe"} // Agent is the developer name, Mohe is the brand name of the assistant
}
// Title Assistant
// Title Assistant (default to system agent)
if setting.Uses.Title == "" {
setting.Uses.Title = setting.Uses.Default
setting.Uses.Title = "__yao.title"
}
// Prompt Assistant
// Prompt Assistant (default to system agent)
if setting.Uses.Prompt == "" {
setting.Uses.Prompt = setting.Uses.Default
setting.Uses.Prompt = "__yao.prompt"
}
// RobotPrompt Assistant (default to system agent)
if setting.Uses.RobotPrompt == "" {
setting.Uses.RobotPrompt = "__yao.robot_prompt"
}
agentDSL = &setting

View file

@ -60,7 +60,6 @@ flowchart TB
subgraph Storage["Storage"]
KB[("KB")]
DB[("DB")]
Job[("Job")]
end
WC --> TC
@ -75,7 +74,7 @@ flowchart TB
TT -->|Clock| P0
TT -->|Human/Event| P1
P0 --> P1 --> P2 --> P3 --> P4 --> P5
P5 --> KB & DB & Job
P5 --> KB & DB
KB -.->|History| P0
```
@ -89,7 +88,7 @@ Executor supports multiple execution modes for different use cases:
| DryRun | Tests, demos, preview without LLM calls | ✅ Implemented |
| Sandbox | Container-isolated for untrusted code | ⬜ Not Implemented |
**Standard Mode:** Real execution with LLM calls, Job integration, full phase execution.
**Standard Mode:** Real execution with LLM calls, full phase execution, logging via kun/log.
**DryRun Mode:** Simulated execution without LLM calls. Used for:
@ -1009,15 +1008,13 @@ stateDiagram-v2
2. Generate member_id if missing
3. Create KB: `robot_{team_id}_{member_id}_kb`
4. Add to cache
5. Create Job
6. Set active
5. Set active
### 6.3 On Delete
1. Stop running jobs
1. Stop running executions
2. Remove from cache
3. Delete Job
4. Delete or archive KB
3. Delete or archive KB
5. Soft delete record
### 6.4 Execution Flow
@ -1065,35 +1062,19 @@ stateDiagram-v2
## 7. Integrations
### 7.1 Job System
### 7.1 Execution Storage
**Relationship:** 1 Robot : N Executions (concurrent), 1 Execution = 1 job.Job
**Relationship:** 1 Robot : N Executions (concurrent)
Each trigger creates a new Execution, mapped to a `job.Job` for monitoring.
Each trigger creates a new Execution, stored in `ExecutionStore` (`__yao.agent_execution` table).
```
┌─────────────────────────────────────────────────────────────────┐
│ Activity Monitor (UI) │
│ • List jobs │
│ • See progress │
│ • View logs │
│ • Cancel/retry │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Job Framework │
│ Job → Execution → Progress → Logs │
└─────────────────────────────────────────────────────────────────┘
```
Execution data includes:
- Status and phase tracking
- All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning)
- Error information
- Timestamps and progress
**Go APIs (yao/job package):**
| Action | API |
| ------------ | -------------------------------------------------------- |
| List Jobs | `job.ListJobs(param, page, pagesize)` |
| Get Job | `job.GetJob(jobID, param)` |
| Save Job | `job.SaveJob(j)` |
Logging is handled by `kun/log` package for standard application logging.
| List Execs | `job.ListExecutions(param, page, pagesize)` |
| Get Exec | `job.GetExecution(execID, param)` |
| Save Exec | `job.SaveExecution(exec)` |
@ -1247,78 +1228,49 @@ type RobotState struct {
}
```
### 8.3 Execution (Uses Job System)
### 8.3 Execution (Uses ExecutionStore)
No separate `autonomous_executions` table. Uses existing Job system.
Uses dedicated `__yao.agent_execution` table via ExecutionStore.
**Each trigger creates a new job.Job:**
**Each trigger creates a new Execution:**
```go
// On each trigger (clock/human/event), create a new Job
execID := gonanoid.Must()
j, _ := job.Once(job.GOROUTINE, map[string]interface{}{
"job_id": "robot_exec_" + execID, // unique per execution
"category_id": "autonomous_robot",
"name": fmt.Sprintf("%s - %s", member.DisplayName, triggerType),
"metadata": map[string]interface{}{
"member_id": memberID,
"team_id": teamID,
"trigger_type": triggerType,
"exec_id": execID,
},
})
job.SaveJob(j)
// Configure and start
j.ExecutionConfig = &job.ExecutionConfig{
Type: job.ExecutionTypeProcess,
ProcessName: "robot.Execute",
ProcessArgs: []interface{}{memberID, execID, triggerData},
// On each trigger (clock/human/event), create a new Execution
exec := &types.Execution{
ID: utils.NewID(),
MemberID: memberID,
TeamID: teamID,
TriggerType: triggerType,
Status: types.ExecStatusRunning,
Phase: types.PhaseP0Init,
StartedAt: time.Now(),
}
j.Push()
// Save to ExecutionStore
execStore.Save(exec)
```
**Query executions for a robot:**
```go
// List all executions for a robot member
param := model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "category_id", Value: "autonomous_robot"},
{Column: "metadata->member_id", Value: memberID},
},
Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}},
}
jobs, _ := job.ListJobs(param, 1, 10)
executions, err := execStore.List(memberID, 1, 10)
```
**Query examples:**
```go
// List all robot jobs (all robots, all executions)
param := model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "category_id", Value: "autonomous_robot"},
},
}
jobs, _ := job.ListJobs(param, 1, 20)
// Get execution by ID
exec, err := execStore.Get(executionID)
// Get executions for a robot
execParam := model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "job_id", Value: "robot_" + memberID},
},
Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}},
}
execs, _ := job.ListExecutions(execParam, 1, 10)
// List executions for a robot
executions, err := execStore.List(memberID, page, pageSize)
// Get logs for an execution
logParam := model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "execution_id", Value: execID},
},
}
logs, _ := job.ListLogs(logParam, 1, 100)
// Update execution status
execStore.UpdateStatus(executionID, types.ExecStatusCompleted)
// Logging via kun/log
log.With(log.F{"execution_id": exec.ID, "phase": "P1"}).Info("Phase started")
```
---

View file

@ -82,11 +82,6 @@ yao/agent/robot/
│ ├── db.go # Database queries
│ └── learning.go # Learning entry save (to KB)
├── job/ # Job system integration
│ ├── job.go # Create/Get job for robot
│ ├── execution.go # Create/Update execution
│ └── log.go # Write execution logs
└── plan/ # Plan queue (deferred tasks)
├── plan.go # Plan queue struct
└── schedule.go # Schedule for later
@ -111,7 +106,7 @@ yao/assert/ # Universal assertion library (global package)
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐┌─────────┐
│ cache ││ dedup ││ store ││ pool ││job ││ plan ││ utils ││ trigger │
│ cache ││ dedup ││ store ││ pool ││ plan ││ utils ││ trigger │
└───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘└────┬────┘
│ │ │ │ │ │
└────────┴────────┴───────┴───────┴────────────────────────┘
@ -145,9 +140,8 @@ yao/assert/ # Universal assertion library (global package)
| `store/` | `types/` |
| `pool/` | `types/` |
| `trigger/` | `types/` |
| `job/` | `types/`, `yao/job` |
| `plan/` | `types/` |
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/`, `yao/assert` |
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `yao/assert` |
| `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
| | Manager handles all trigger logic (clock, intervene, event) |
| `api/` | `types/`, `manager/` |
@ -304,7 +298,6 @@ type TriggerResult struct {
Accepted bool `json:"accepted"` // whether trigger was accepted
Queued bool `json:"queued"` // true if queued (quota full)
Execution *types.Execution `json:"execution,omitempty"` // execution info if started
JobID string `json:"job_id,omitempty"` // job ID for tracking
Message string `json:"message,omitempty"` // status message
}
@ -524,7 +517,6 @@ interface TriggerResult {
accepted: boolean;
queued: boolean;
execution?: Execution;
job_id?: string;
message?: string;
}
@ -1160,7 +1152,7 @@ import (
// Robot - runtime representation of an autonomous robot (from __yao.member)
// Relationship: 1 Robot : N Executions (concurrent)
// Each trigger creates a new Execution (mapped to job.Job)
// Each trigger creates a new Execution (stored in ExecutionStore)
type Robot struct {
// From __yao.member
MemberID string `json:"member_id"`
@ -1234,8 +1226,7 @@ func (r *Robot) GetExecutions() []*Execution {
}
// Execution - single execution instance
// Each trigger creates a new Execution, mapped to a job.Job for monitoring
// Relationship: 1 Execution = 1 job.Job
// Each trigger creates a new Execution, stored in ExecutionStore
type Execution struct {
ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID (globally unique)
@ -1247,8 +1238,6 @@ type Execution struct {
Phase Phase `json:"phase"`
Error string `json:"error,omitempty"`
// Job integration (each Execution = 1 job.Job)
JobID string `json:"job_id"` // corresponding job.Job ID
// Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input
@ -2392,7 +2381,6 @@ type ExecutionRecord struct {
ExecutionID string `json:"execution_id"` // Unique execution identifier
MemberID string `json:"member_id"` // Robot member ID (globally unique)
TeamID string `json:"team_id"` // Team ID
JobID string `json:"job_id,omitempty"` // Linked job.Job ID
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
// Status tracking (synced with runtime Execution)

View file

@ -165,7 +165,6 @@ Create empty structs and stub methods that return nil/empty/success:
- [x] `dedup/dedup.go` - Dedup struct, stub methods
- [x] `store/store.go` - Store struct, stub methods
- [x] `pool/pool.go` - Pool struct, stub methods
- [x] `job/job.go` - job helper stubs
- [x] `plan/plan.go` - Plan struct, stub methods
- [x] `trigger/trigger.go` - trigger dispatcher stub
- [x] `executor/executor.go` - Executor struct, stub `Execute()`
@ -278,35 +277,16 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
- [x] ExecutionController lifecycle tests
- [x] Manager integration tests for Intervene/HandleEvent
### ✅ 3.5 Job Integration (COMPLETE)
### ✅ 3.5 Execution Storage (COMPLETE)
- [x] `job/job.go` - create job
- [x] `job_id`: `robot_exec_{execID}`
- [x] `category_name`: `Autonomous Robot` / `自主机器人` (localized)
- [x] Metadata: member_id, team_id, trigger_type, exec_id, display_name
- [x] `Options` struct for extensibility (Priority, MaxRetryCount, DefaultTimeout, Metadata)
- [x] `Create()`, `Get()`, `Update()`, `Complete()`, `Fail()`, `Cancel()`
- [x] Status mapping: ExecPending→queued, ExecRunning→running, etc.
- [x] ExecutionStore - execution record persistence
- [x] Execution data stored in `__yao.agent_execution` table
- [x] All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning)
- [x] Status and phase tracking
- [x] Logging via `kun/log` package
- [x] Localization support (en-US, zh-CN)
- [x] `job/execution.go` - execution lifecycle
- [x] `CreateOptions` struct for extensibility
- [x] `CreateExecution()` - create both robot Execution and job.Execution
- [x] `UpdatePhase()` - update phase with progress tracking (10%→25%→40%→60%→80%→95%)
- [x] `UpdateStatus()` - update execution status
- [x] `CompleteExecution()` / `FailExecution()` / `CancelExecution()`
- [x] TriggerType → TriggerCategory mapping (clock→scheduled, human→manual, event→event)
- [x] Duration calculation on completion/failure/cancellation
- [x] `job/log.go` - write phase logs
- [x] `Log()` - base log function with context
- [x] `LogPhaseStart()` / `LogPhaseEnd()` / `LogPhaseError()`
- [x] `LogError()` / `LogInfo()` / `LogDebug()` / `LogWarn()`
- [x] `LogTaskStart()` / `LogTaskEnd()`
- [x] `LogDelivery()` / `LogLearning()`
- [x] Localization support for all log messages
- [x] Test: job creation, execution tracking, log writing
- [x] `job/job_test.go` - 17 test cases
- [x] `job/execution_test.go` - 26 test cases
- [x] `job/log_test.go` - 24 test cases
- [x] Test: execution storage, status tracking
- [x] `store/execution_test.go` - execution store tests
- [x] All tests passing with real database
### ✅ 3.6 Executor Architecture (COMPLETE)
@ -875,7 +855,7 @@ Created new `yao/assert` package for universal assertion/validation:
- [x] `yao/models/agent/execution.mod.yao` - Execution record model (`agent_execution` table)
- [x] id, execution_id (unique)
- [x] member_id (globally unique), team_id, job_id
- [x] member_id (globally unique), team_id
- [x] trigger_type (enum: clock, human, event)
- [x] **Status tracking** (synced with runtime Execution):
- [x] status (enum: pending, running, completed, failed, cancelled)

View file

@ -19,7 +19,7 @@ result, _ := api.Trigger(ctx, "member_123", &api.TriggerRequest{
})
// Check status
exec, _ := api.GetExecution(ctx, result.JobID)
exec, _ := api.GetExecution(ctx, result.ExecutionID)
```
## Lifecycle
@ -148,11 +148,11 @@ type TriggerRequest struct {
```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
Accepted bool // Whether trigger was accepted
Queued bool // Whether queued (vs immediate)
Execution *types.Execution // Execution details
ExecutionID string // Execution ID for tracking
Message string // Status message
}
```

View file

@ -102,7 +102,7 @@ func TestAPIFullLifecycle(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, triggerResult)
assert.True(t, triggerResult.Accepted)
assert.NotEmpty(t, triggerResult.JobID)
assert.NotEmpty(t, triggerResult.ExecutionID)
// 7. Wait for execution to complete
time.Sleep(500 * time.Millisecond)
@ -205,6 +205,75 @@ func TestAPIRobotQueryWithData(t *testing.T) {
})
}
// TestListRobotsAutonomousModeFilter tests the autonomous_mode filter
func TestListRobotsAutonomousModeFilter(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 robots with different autonomous_mode settings
setupAPITestRobotWithMode(t, "robot_api_auto_001", "team_api_mode", true) // autonomous
setupAPITestRobotWithMode(t, "robot_api_auto_002", "team_api_mode", true) // autonomous
setupAPITestRobotWithMode(t, "robot_api_demand_001", "team_api_mode", false) // on-demand
ctx := types.NewContext(context.Background(), nil)
t.Run("ListRobots returns all robots when autonomous_mode is nil", func(t *testing.T) {
result, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_mode",
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
require.NotNil(t, result)
// Should have all 3 robots
assert.Equal(t, 3, result.Total)
})
t.Run("ListRobots filters by autonomous_mode=true", func(t *testing.T) {
autonomousMode := true
result, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_mode",
AutonomousMode: &autonomousMode,
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
require.NotNil(t, result)
// Should have only 2 autonomous robots
assert.Equal(t, 2, result.Total)
for _, robot := range result.Data {
assert.True(t, robot.AutonomousMode, "All returned robots should be autonomous")
}
})
t.Run("ListRobots filters by autonomous_mode=false", func(t *testing.T) {
autonomousMode := false
result, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_mode",
AutonomousMode: &autonomousMode,
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
require.NotNil(t, result)
// Should have only 1 on-demand robot
assert.Equal(t, 1, result.Total)
for _, robot := range result.Data {
assert.False(t, robot.AutonomousMode, "All returned robots should be on-demand")
}
})
}
// TestAPIExecutionQueryWithData tests execution query APIs with real data
func TestAPIExecutionQueryWithData(t *testing.T) {
if testing.Short() {
@ -333,7 +402,7 @@ func TestAPITriggerWithData(t *testing.T) {
require.NotNil(t, result)
assert.True(t, result.Accepted)
assert.NotEmpty(t, result.JobID)
assert.NotEmpty(t, result.ExecutionID)
assert.Contains(t, result.Message, "submitted")
})
@ -377,6 +446,44 @@ func TestAPITriggerWithData(t *testing.T) {
// ==================== Helper Functions ====================
// setupAPITestRobotWithMode creates a test robot with specific autonomous_mode setting
func setupAPITestRobotWithMode(t *testing.T, memberID, teamID string, autonomousMode bool) {
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,
},
}
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": autonomousMode,
"robot_status": "idle",
"robot_config": string(configJSON),
},
})
if err != nil {
t.Fatalf("Failed to insert robot %s: %v", memberID, err)
}
}
// setupAPITestRobot creates a test robot in the database
func setupAPITestRobot(t *testing.T, memberID, teamID string) {
m := model.Select("__yao.member")
@ -435,7 +542,6 @@ func setupAPITestExecution(t *testing.T, execID, memberID string, triggerType ty
ExecutionID: execID,
MemberID: memberID,
TeamID: "team_api_exec",
JobID: "job_" + execID,
TriggerType: triggerType,
Status: status,
Phase: types.PhaseDelivery,
@ -462,11 +568,17 @@ func cleanupAPITestRobots(t *testing.T) {
tableName := m.MetaData.Table.Name
qb := capsule.Query()
// Delete all robots with member_id starting with "robot_api_"
// Delete all robots with member_id starting with "robot_api_" or "api_robot_"
_, err := qb.Table(tableName).Where("member_id", "like", "robot_api_%").Delete()
if err != nil {
t.Logf("Warning: cleanup robots error: %v", err)
}
// Also delete "api_robot_" prefixed robots (new tests)
_, err = qb.Table(tableName).Where("member_id", "like", "api_robot_%").Delete()
if err != nil {
t.Logf("Warning: cleanup robots error: %v", err)
}
}
// cleanupAPITestExecutions removes all API test executions

View file

@ -71,9 +71,9 @@ func TestE2EClockTriggerFullFlow(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.Accepted, "Clock trigger should be accepted: %s", result.Message)
assert.NotEmpty(t, result.JobID, "Should return job ID")
assert.NotEmpty(t, result.ExecutionID, "Should return execution ID")
t.Logf("Execution started: JobID=%s", result.JobID)
t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
// Wait for execution to complete (real LLM calls take time)
// P0→P4 typically takes 30-60 seconds with real LLM

View file

@ -90,7 +90,7 @@ func TestE2EConcurrentMultipleRobots(t *testing.T) {
if result.Accepted {
acceptedCount.Add(1)
t.Logf("Robot %s accepted: JobID=%s", id, result.JobID)
t.Logf("Robot %s accepted: ExecutionID=%s", id, result.ExecutionID)
}
}(i, memberID)
}
@ -176,7 +176,7 @@ func TestE2EConcurrentSameRobotMultipleTriggers(t *testing.T) {
if result.Accepted {
acceptedCount.Add(1)
t.Logf("Trigger %d accepted: JobID=%s", idx, result.JobID)
t.Logf("Trigger %d accepted: ExecutionID=%s", idx, result.ExecutionID)
} else {
t.Logf("Trigger %d rejected: %s", idx, result.Message)
}

View file

@ -61,7 +61,7 @@ func TestE2EControlPauseResume(t *testing.T) {
require.NoError(t, err)
require.True(t, result.Accepted)
t.Logf("Execution started: JobID=%s", result.JobID)
t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
// Wait for execution to start running
var execID string
@ -166,7 +166,7 @@ func TestE2EControlStop(t *testing.T) {
require.NoError(t, err)
require.True(t, result.Accepted)
t.Logf("Execution started: JobID=%s", result.JobID)
t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
// Wait for execution to start running
var execID string

View file

@ -83,7 +83,7 @@ func TestE2EEventTriggerFullFlow(t *testing.T) {
require.NotNil(t, result)
assert.True(t, result.Accepted, "Event trigger should be accepted")
t.Logf("Event trigger result: Accepted=%v, JobID=%s", result.Accepted, result.JobID)
t.Logf("Event trigger result: Accepted=%v, ExecutionID=%s", result.Accepted, result.ExecutionID)
// Wait for execution to complete
var exec *types.Execution
@ -256,7 +256,7 @@ func TestE2EEventTriggerVariousEventTypes(t *testing.T) {
require.NotNil(t, result)
assert.True(t, result.Accepted, "Event should be accepted")
t.Logf("Event triggered: JobID=%s", result.JobID)
t.Logf("Event triggered: ExecutionID=%s", result.ExecutionID)
// Wait for execution
maxWait := 120 * time.Second

View file

@ -1,10 +1,14 @@
package api
import (
"context"
"fmt"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/agent/robot/store"
"github.com/yaoapp/yao/agent/robot/types"
)
@ -14,6 +18,9 @@ import (
// memberModel is the model name for member table
const memberModel = "__yao.member"
// robotStore is the shared robot store instance
var robotStore = store.NewRobotStore()
// 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) {
@ -77,15 +84,25 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
return nil, err
}
// Get permission fields from store (for access control)
record, _ := robotStore.Get(context.Background(), memberID)
state := &RobotState{
MemberID: robot.MemberID,
TeamID: robot.TeamID,
DisplayName: robot.DisplayName,
Bio: robot.Bio,
Status: robot.Status,
Running: robot.RunningCount(),
MaxRunning: 2, // default
}
// Add permission fields if available
if record != nil {
state.YaoCreatedBy = record.YaoCreatedBy
state.YaoTeamID = record.YaoTeamID
}
if robot.Config != nil && robot.Config.Quota != nil {
state.MaxRunning = robot.Config.Quota.GetMax()
}
@ -121,7 +138,7 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) {
records, err := m.Get(model.QueryParam{
Select: []interface{}{
"id", "member_id", "team_id", "display_name",
"id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email",
},
@ -152,7 +169,6 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
// Build where conditions
wheres := []model.QueryWhere{
{Column: "member_type", Value: "robot"},
{Column: "autonomous_mode", Value: true},
{Column: "status", Value: "active"},
}
@ -169,6 +185,9 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
Value: "%" + query.Keywords + "%",
})
}
if query.AutonomousMode != nil {
wheres = append(wheres, model.QueryWhere{Column: "autonomous_mode", Value: *query.AutonomousMode})
}
// Build order
orders := []model.QueryOrder{}
@ -181,7 +200,7 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
// Execute paginated query
result, err := m.Paginate(model.QueryParam{
Select: []interface{}{
"id", "member_id", "team_id", "display_name",
"id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email",
},
@ -256,3 +275,375 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult {
PageSize: query.PageSize,
}
}
// ==================== Robot CRUD API ====================
// These functions create, update, and delete robots
// They call store layer for persistence and manage cache
// Request/Response types are defined in types.go
// CreateRobot creates a new robot member
// Calls store.RobotStore.Save() and refreshes cache
// If member_id is not provided, it will be auto-generated
func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, error) {
// Validate required fields
if req.TeamID == "" {
return nil, fmt.Errorf("team_id is required")
}
if req.DisplayName == "" {
return nil, fmt.Errorf("display_name is required")
}
// Generate member_id if not provided
if req.MemberID == "" {
generatedID, err := generateMemberID(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to generate member_id: %w", err)
}
req.MemberID = generatedID
}
// Check if robot already exists
existing, err := robotStore.Get(context.Background(), req.MemberID)
if err != nil {
return nil, fmt.Errorf("failed to check existing robot: %w", err)
}
if existing != nil {
return nil, fmt.Errorf("robot with member_id '%s' already exists", req.MemberID)
}
// Determine autonomous_mode value
autonomousMode := false
if req.AutonomousMode != nil {
autonomousMode = *req.AutonomousMode
}
// Determine status values
status := "active"
if req.Status != "" {
status = req.Status
}
robotStatus := "idle"
if req.RobotStatus != "" {
robotStatus = req.RobotStatus
}
// Create store record with all fields
now := time.Now()
record := &store.RobotRecord{
// Required
MemberID: req.MemberID,
TeamID: req.TeamID,
MemberType: "robot",
Status: status,
RobotStatus: robotStatus,
AutonomousMode: autonomousMode,
// Profile
DisplayName: req.DisplayName,
Bio: req.Bio,
Avatar: req.Avatar,
// Identity & Role
SystemPrompt: req.SystemPrompt,
RoleID: req.RoleID,
ManagerID: req.ManagerID,
// Communication
RobotEmail: req.RobotEmail,
AuthorizedSenders: req.AuthorizedSenders,
EmailFilterRules: req.EmailFilterRules,
// Capabilities
RobotConfig: req.RobotConfig,
Agents: req.Agents,
MCPServers: req.MCPServers,
LanguageModel: req.LanguageModel,
// Limits
CostLimit: req.CostLimit,
// Timestamps
JoinedAt: &now,
}
// Apply Yao permission fields if provided
if req.AuthScope != nil {
record.YaoCreatedBy = req.AuthScope.CreatedBy
record.YaoTeamID = req.AuthScope.TeamID
record.YaoTenantID = req.AuthScope.TenantID
// Set invited_by from CreatedBy if not explicitly set
if record.InvitedBy == "" && req.AuthScope.CreatedBy != "" {
record.InvitedBy = req.AuthScope.CreatedBy
}
}
// Save to database
err = robotStore.Save(context.Background(), record)
if err != nil {
return nil, fmt.Errorf("failed to create robot: %w", err)
}
// Refresh cache if manager is running
// Use Refresh() which handles autonomous_mode correctly:
// - If autonomous_mode=true: adds to cache for scheduling
// - If autonomous_mode=false: does not add to cache
mgr, err := getManager()
if err == nil && mgr != nil {
_ = mgr.Cache().Refresh(ctx, req.MemberID)
}
// Return the created robot as response
return GetRobotResponse(ctx, req.MemberID)
}
// UpdateRobot updates an existing robot member
// Calls store.RobotStore.Save() and refreshes cache
func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (*RobotResponse, error) {
if memberID == "" {
return nil, fmt.Errorf("member_id is required")
}
// Get existing record
existing, err := robotStore.Get(context.Background(), memberID)
if err != nil {
return nil, fmt.Errorf("failed to get robot: %w", err)
}
if existing == nil {
return nil, types.ErrRobotNotFound
}
// Apply updates - only non-nil fields are updated
// Profile
if req.DisplayName != nil {
existing.DisplayName = *req.DisplayName
}
if req.Bio != nil {
existing.Bio = *req.Bio
}
if req.Avatar != nil {
existing.Avatar = *req.Avatar
}
// Identity & Role
if req.SystemPrompt != nil {
existing.SystemPrompt = *req.SystemPrompt
}
if req.RoleID != nil {
existing.RoleID = *req.RoleID
}
if req.ManagerID != nil {
existing.ManagerID = *req.ManagerID
}
// Status
if req.Status != nil {
existing.Status = *req.Status
}
if req.RobotStatus != nil {
existing.RobotStatus = *req.RobotStatus
}
if req.AutonomousMode != nil {
existing.AutonomousMode = *req.AutonomousMode
}
// Communication
if req.RobotEmail != nil {
existing.RobotEmail = *req.RobotEmail
}
if req.AuthorizedSenders != nil {
existing.AuthorizedSenders = req.AuthorizedSenders
}
if req.EmailFilterRules != nil {
existing.EmailFilterRules = req.EmailFilterRules
}
// Capabilities
if req.RobotConfig != nil {
existing.RobotConfig = req.RobotConfig
}
if req.Agents != nil {
existing.Agents = req.Agents
}
if req.MCPServers != nil {
existing.MCPServers = req.MCPServers
}
if req.LanguageModel != nil {
existing.LanguageModel = *req.LanguageModel
}
// Limits
if req.CostLimit != nil {
existing.CostLimit = *req.CostLimit
}
// Apply Yao permission fields if provided (update scope)
if req.AuthScope != nil {
existing.YaoUpdatedBy = req.AuthScope.UpdatedBy
// Team and Tenant are typically set on create, not update
// But allow override if explicitly provided
if req.AuthScope.TeamID != "" {
existing.YaoTeamID = req.AuthScope.TeamID
}
if req.AuthScope.TenantID != "" {
existing.YaoTenantID = req.AuthScope.TenantID
}
}
// Save to database
err = robotStore.Save(context.Background(), existing)
if err != nil {
return nil, fmt.Errorf("failed to update robot: %w", err)
}
// Refresh cache if manager is running
// Use Refresh() which handles autonomous_mode correctly:
// - If autonomous_mode=true: adds to cache for scheduling
// - If autonomous_mode=false: removes from cache
mgr, err := getManager()
if err == nil && mgr != nil {
_ = mgr.Cache().Refresh(ctx, memberID) // Ignore error, database is already saved
}
// Return the updated robot as response
return GetRobotResponse(ctx, memberID)
}
// RemoveRobot deletes a robot member
// Calls store.RobotStore.Delete() and invalidates cache
func RemoveRobot(ctx *types.Context, memberID string) error {
if memberID == "" {
return fmt.Errorf("member_id is required")
}
// Check if robot exists
existing, err := robotStore.Get(context.Background(), memberID)
if err != nil {
return fmt.Errorf("failed to get robot: %w", err)
}
if existing == nil {
return types.ErrRobotNotFound
}
// Check if robot has running executions
mgr, err := getManager()
if err == nil && mgr != nil {
robot := mgr.Cache().Get(memberID)
if robot != nil && robot.RunningCount() > 0 {
return fmt.Errorf("cannot delete robot with running executions")
}
}
// Delete from database
err = robotStore.Delete(context.Background(), memberID)
if err != nil {
return fmt.Errorf("failed to delete robot: %w", err)
}
// Invalidate cache if manager is running
if mgr != nil {
mgr.Cache().Remove(memberID)
}
return nil
}
// GetRobotResponse retrieves a robot and converts to API response format
func GetRobotResponse(ctx *types.Context, memberID string) (*RobotResponse, error) {
record, err := robotStore.Get(context.Background(), memberID)
if err != nil {
return nil, fmt.Errorf("failed to get robot: %w", err)
}
if record == nil {
return nil, types.ErrRobotNotFound
}
return recordToResponse(record), nil
}
// recordToResponse converts a store.RobotRecord to API RobotResponse
func recordToResponse(record *store.RobotRecord) *RobotResponse {
return &RobotResponse{
ID: record.ID,
MemberID: record.MemberID,
TeamID: record.TeamID,
Status: record.Status,
RobotStatus: record.RobotStatus,
AutonomousMode: record.AutonomousMode,
DisplayName: record.DisplayName,
Bio: record.Bio,
Avatar: record.Avatar,
SystemPrompt: record.SystemPrompt,
RoleID: record.RoleID,
ManagerID: record.ManagerID,
RobotEmail: record.RobotEmail,
AuthorizedSenders: record.AuthorizedSenders,
EmailFilterRules: record.EmailFilterRules,
RobotConfig: record.RobotConfig,
Agents: record.Agents,
MCPServers: record.MCPServers,
LanguageModel: record.LanguageModel,
CostLimit: record.CostLimit,
InvitedBy: record.InvitedBy,
JoinedAt: record.JoinedAt,
YaoCreatedBy: record.YaoCreatedBy,
YaoTeamID: record.YaoTeamID,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
}
}
// ==================== Member ID Generation ====================
// generateMemberID generates a unique member_id with collision detection
// Uses 12-digit numeric ID to match existing pattern in openapi/oauth/providers/user
func generateMemberID(ctx context.Context) (string, error) {
const maxRetries = 10
for i := 0; i < maxRetries; i++ {
// Generate 12-digit numeric ID
id, err := gonanoid.Generate("0123456789", 12)
if err != nil {
return "", fmt.Errorf("failed to generate member_id: %w", err)
}
// Check if ID already exists
exists, err := memberIDExists(ctx, id)
if err != nil {
return "", fmt.Errorf("failed to check member_id existence: %w", err)
}
if !exists {
return id, nil
}
// ID exists, retry
}
return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries)
}
// memberIDExists checks if a member_id already exists in the database
func memberIDExists(ctx context.Context, memberID string) (bool, error) {
m := model.Select(memberModel)
if m == nil {
return false, fmt.Errorf("model %s not found", memberModel)
}
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"},
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return false, err
}
return len(members) > 0, nil
}

View file

@ -5,6 +5,7 @@ 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"
@ -100,3 +101,376 @@ func TestGetRobotStatusValidation(t *testing.T) {
assert.Nil(t, status)
})
}
// ==================== Robot CRUD API Tests ====================
// TestCreateRobotValidation tests parameter validation for CreateRobot
func TestCreateRobotValidation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("auto_generates_member_id_when_empty", func(t *testing.T) {
req := &api.CreateRobotRequest{
MemberID: "",
TeamID: "team_001",
DisplayName: "Test Robot Auto ID",
}
result, err := api.CreateRobot(ctx, req)
require.NoError(t, err)
require.NotNil(t, result)
// Verify member_id was auto-generated (12-digit numeric)
assert.NotEmpty(t, result.MemberID)
assert.Len(t, result.MemberID, 12, "Auto-generated member_id should be 12 digits")
// Cleanup
_ = api.RemoveRobot(ctx, result.MemberID)
})
t.Run("returns_error_for_empty_team_id", func(t *testing.T) {
req := &api.CreateRobotRequest{
MemberID: "robot_test_001",
TeamID: "",
DisplayName: "Test Robot",
}
result, err := api.CreateRobot(ctx, req)
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "team_id is required")
})
t.Run("returns_error_for_empty_display_name", func(t *testing.T) {
req := &api.CreateRobotRequest{
MemberID: "robot_test_001",
TeamID: "team_001",
DisplayName: "",
}
result, err := api.CreateRobot(ctx, req)
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "display_name is required")
})
}
// TestCreateRobot tests the CreateRobot API function
func TestCreateRobot(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Cleanup before and after
cleanupAPITestRobots(t)
defer cleanupAPITestRobots(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("creates_robot_with_required_fields", func(t *testing.T) {
req := &api.CreateRobotRequest{
MemberID: "api_robot_create_001",
TeamID: "api_team_001",
DisplayName: "API Test Robot",
}
result, err := api.CreateRobot(ctx, req)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "api_robot_create_001", result.MemberID)
assert.Equal(t, "api_team_001", result.TeamID)
assert.Equal(t, "API Test Robot", result.DisplayName)
assert.Equal(t, "active", result.Status)
assert.Equal(t, "idle", result.RobotStatus)
})
t.Run("creates_robot_with_all_fields", func(t *testing.T) {
autonomousMode := true
req := &api.CreateRobotRequest{
MemberID: "api_robot_create_002",
TeamID: "api_team_002",
DisplayName: "Full Robot",
Bio: "A fully configured robot",
SystemPrompt: "You are a helpful assistant",
Avatar: "https://example.com/avatar.png",
RoleID: "admin",
ManagerID: "user_001",
AutonomousMode: &autonomousMode,
RobotEmail: "fullrobot@test.com",
LanguageModel: "gpt-4",
CostLimit: 100.0,
RobotConfig: map[string]interface{}{
"clock_mode": "on",
"max_concurrent": 3,
},
}
result, err := api.CreateRobot(ctx, req)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "api_robot_create_002", result.MemberID)
assert.Equal(t, "Full Robot", result.DisplayName)
assert.Equal(t, "A fully configured robot", result.Bio)
assert.Equal(t, "You are a helpful assistant", result.SystemPrompt)
assert.Equal(t, "admin", result.RoleID)
assert.True(t, result.AutonomousMode)
assert.Equal(t, "fullrobot@test.com", result.RobotEmail)
assert.Equal(t, "gpt-4", result.LanguageModel)
assert.Equal(t, 100.0, result.CostLimit)
})
t.Run("creates_robot_with_auth_scope", func(t *testing.T) {
req := &api.CreateRobotRequest{
MemberID: "api_robot_create_003",
TeamID: "api_team_003",
DisplayName: "Robot with Auth",
AuthScope: &api.AuthScope{
CreatedBy: "user_123",
TeamID: "perm_team_001",
TenantID: "tenant_001",
},
}
result, err := api.CreateRobot(ctx, req)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "api_robot_create_003", result.MemberID)
// InvitedBy should be set from AuthScope.CreatedBy
assert.Equal(t, "user_123", result.InvitedBy)
})
t.Run("returns_error_for_duplicate_member_id", func(t *testing.T) {
req := &api.CreateRobotRequest{
MemberID: "api_robot_create_001", // Already created above
TeamID: "api_team_001",
DisplayName: "Duplicate Robot",
}
result, err := api.CreateRobot(ctx, req)
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "already exists")
})
}
// TestUpdateRobot tests the UpdateRobot API function
func TestUpdateRobot(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupAPITestRobots(t)
defer cleanupAPITestRobots(t)
ctx := types.NewContext(context.Background(), nil)
// Create a robot to update
createReq := &api.CreateRobotRequest{
MemberID: "api_robot_update_001",
TeamID: "api_team_update",
DisplayName: "Original Name",
Bio: "Original bio",
}
_, err := api.CreateRobot(ctx, createReq)
require.NoError(t, err)
t.Run("returns_error_for_empty_member_id", func(t *testing.T) {
req := &api.UpdateRobotRequest{}
result, err := api.UpdateRobot(ctx, "", req)
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "member_id is required")
})
t.Run("returns_error_for_non_existent_robot", func(t *testing.T) {
newName := "New Name"
req := &api.UpdateRobotRequest{
DisplayName: &newName,
}
result, err := api.UpdateRobot(ctx, "non_existent_robot", req)
assert.Error(t, err)
assert.Nil(t, result)
})
t.Run("updates_display_name", func(t *testing.T) {
newName := "Updated Name"
req := &api.UpdateRobotRequest{
DisplayName: &newName,
}
result, err := api.UpdateRobot(ctx, "api_robot_update_001", req)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "Updated Name", result.DisplayName)
// Bio should be unchanged
assert.Equal(t, "Original bio", result.Bio)
})
t.Run("updates_multiple_fields", func(t *testing.T) {
newBio := "New bio description"
newPrompt := "Updated system prompt"
autonomousMode := true
req := &api.UpdateRobotRequest{
Bio: &newBio,
SystemPrompt: &newPrompt,
AutonomousMode: &autonomousMode,
}
result, err := api.UpdateRobot(ctx, "api_robot_update_001", req)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "New bio description", result.Bio)
assert.Equal(t, "Updated system prompt", result.SystemPrompt)
assert.True(t, result.AutonomousMode)
})
t.Run("updates_robot_status", func(t *testing.T) {
newStatus := "working"
req := &api.UpdateRobotRequest{
RobotStatus: &newStatus,
}
result, err := api.UpdateRobot(ctx, "api_robot_update_001", req)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "working", result.RobotStatus)
})
t.Run("updates_config", func(t *testing.T) {
newConfig := map[string]interface{}{
"clock_mode": "off",
"max_concurrent": 5,
}
req := &api.UpdateRobotRequest{
RobotConfig: newConfig,
}
result, err := api.UpdateRobot(ctx, "api_robot_update_001", req)
require.NoError(t, err)
require.NotNil(t, result)
assert.NotNil(t, result.RobotConfig)
})
}
// TestRemoveRobot tests the RemoveRobot API function
func TestRemoveRobot(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupAPITestRobots(t)
defer cleanupAPITestRobots(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("returns_error_for_empty_member_id", func(t *testing.T) {
err := api.RemoveRobot(ctx, "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "member_id is required")
})
t.Run("returns_error_for_non_existent_robot", func(t *testing.T) {
err := api.RemoveRobot(ctx, "non_existent_robot")
assert.Error(t, err)
})
t.Run("removes_existing_robot", func(t *testing.T) {
// Create a robot
createReq := &api.CreateRobotRequest{
MemberID: "api_robot_remove_001",
TeamID: "api_team_remove",
DisplayName: "Robot to Remove",
}
_, err := api.CreateRobot(ctx, createReq)
require.NoError(t, err)
// Verify it exists
robot, err := api.GetRobot(ctx, "api_robot_remove_001")
require.NoError(t, err)
require.NotNil(t, robot)
// Remove it
err = api.RemoveRobot(ctx, "api_robot_remove_001")
require.NoError(t, err)
// Verify it's gone
robot, err = api.GetRobot(ctx, "api_robot_remove_001")
assert.Error(t, err) // Should return error for non-existent
})
}
// TestGetRobotResponse tests the GetRobotResponse API function
func TestGetRobotResponse(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupAPITestRobots(t)
defer cleanupAPITestRobots(t)
ctx := types.NewContext(context.Background(), nil)
// Create a robot
autonomousMode := true
createReq := &api.CreateRobotRequest{
MemberID: "api_robot_response_001",
TeamID: "api_team_response",
DisplayName: "Response Test Robot",
Bio: "Test bio for response",
SystemPrompt: "Test prompt",
AutonomousMode: &autonomousMode,
RobotEmail: "response@test.com",
CostLimit: 50.0,
}
_, err := api.CreateRobot(ctx, createReq)
require.NoError(t, err)
t.Run("returns_robot_response_format", func(t *testing.T) {
result, err := api.GetRobotResponse(ctx, "api_robot_response_001")
require.NoError(t, err)
require.NotNil(t, result)
// Verify all fields are present in response
assert.Equal(t, "api_robot_response_001", result.MemberID)
assert.Equal(t, "api_team_response", result.TeamID)
assert.Equal(t, "Response Test Robot", result.DisplayName)
assert.Equal(t, "Test bio for response", result.Bio)
assert.Equal(t, "Test prompt", result.SystemPrompt)
assert.True(t, result.AutonomousMode)
assert.Equal(t, "response@test.com", result.RobotEmail)
assert.Equal(t, 50.0, result.CostLimit)
assert.Equal(t, "active", result.Status)
assert.Equal(t, "idle", result.RobotStatus)
})
t.Run("returns_error_for_non_existent", func(t *testing.T) {
result, err := api.GetRobotResponse(ctx, "non_existent")
assert.Error(t, err)
assert.Nil(t, result)
})
}
// Note: cleanupAPITestRobots is defined in api_test.go (shared helper)

View file

@ -57,9 +57,9 @@ func TriggerManual(ctx *types.Context, memberID string, triggerType types.Trigge
}
return &TriggerResult{
Accepted: true,
JobID: execID,
Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType),
Accepted: true,
ExecutionID: execID,
Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType),
}, nil
}
@ -123,9 +123,9 @@ func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req
}
return &TriggerResult{
Accepted: true,
JobID: result.ExecutionID,
Message: result.Message,
Accepted: true,
ExecutionID: result.ExecutionID,
Message: result.Message,
}, nil
}
@ -150,9 +150,9 @@ func triggerEvent(ctx *types.Context, mgr managerInterface, memberID string, req
}
return &TriggerResult{
Accepted: true,
JobID: result.ExecutionID,
Message: result.Message,
Accepted: true,
ExecutionID: result.ExecutionID,
Message: result.Message,
}, nil
}
@ -173,9 +173,9 @@ func triggerManual(ctx *types.Context, mgr managerInterface, memberID string, re
}
return &TriggerResult{
Accepted: true,
JobID: execID,
Message: fmt.Sprintf("Trigger (%s) submitted", req.Type),
Accepted: true,
ExecutionID: execID,
Message: fmt.Sprintf("Trigger (%s) submitted", req.Type),
}, nil
}

View file

@ -9,13 +9,14 @@ import (
// 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"`
TeamID string `json:"team_id,omitempty"`
Status types.RobotStatus `json:"status,omitempty"`
Keywords string `json:"keywords,omitempty"`
ClockMode types.ClockMode `json:"clock_mode,omitempty"`
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // nil=all, true=autonomous only, false=on-demand only
Page int `json:"page,omitempty"`
PageSize int `json:"pagesize,omitempty"`
Order string `json:"order,omitempty"`
}
// ListResult - result of List()
@ -28,15 +29,18 @@ type ListResult struct {
// 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"`
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
DisplayName string `json:"display_name"`
Bio string `json:"bio,omitempty"`
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"`
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id for permission check
YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for permission check
}
// ==================== Trigger Types ====================
@ -78,11 +82,11 @@ const (
// 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"`
Accepted bool `json:"accepted"`
Queued bool `json:"queued"`
Execution *types.Execution `json:"execution,omitempty"`
ExecutionID string `json:"execution_id,omitempty"` // Execution ID
Message string `json:"message,omitempty"`
}
// ==================== Execution Types ====================
@ -103,6 +107,136 @@ type ExecutionResult struct {
PageSize int `json:"pagesize"`
}
// ==================== CRUD Types ====================
// AuthScope contains Yao permission fields for data scoping
// These fields are used by Yao's permission system (when model has permission: true)
type AuthScope struct {
CreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id
UpdatedBy string `json:"__yao_updated_by,omitempty"` // Updater user_id
TeamID string `json:"__yao_team_id,omitempty"` // Permission team scope
TenantID string `json:"__yao_tenant_id,omitempty"` // Permission tenant scope
}
// CreateRobotRequest - request for CreateRobot()
type CreateRobotRequest struct {
// Identity (member_id is optional - auto-generated if not provided)
MemberID string `json:"member_id,omitempty"` // Unique robot identifier (auto-generated if empty)
TeamID string `json:"team_id"` // Team ID (required)
// Profile
DisplayName string `json:"display_name,omitempty"` // Display name
Bio string `json:"bio,omitempty"` // Robot description
Avatar string `json:"avatar,omitempty"` // Avatar URL
// Identity & Role
SystemPrompt string `json:"system_prompt,omitempty"` // System prompt
RoleID string `json:"role_id,omitempty"` // Role within team
ManagerID string `json:"manager_id,omitempty"` // Direct manager user_id
// Status
Status string `json:"status,omitempty"` // Member status: active | inactive | pending | suspended
RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle | working | paused | error | maintenance
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled
// Communication
RobotEmail string `json:"robot_email,omitempty"` // Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
// Capabilities
RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
LanguageModel string `json:"language_model,omitempty"` // Language model name
// Limits
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
// Auth scope (optional, used by OpenAPI layer via WithCreateScope)
AuthScope *AuthScope `json:"auth_scope,omitempty"`
}
// UpdateRobotRequest - request for UpdateRobot()
type UpdateRobotRequest struct {
// Profile
DisplayName *string `json:"display_name,omitempty"` // Display name
Bio *string `json:"bio,omitempty"` // Robot description
Avatar *string `json:"avatar,omitempty"` // Avatar URL
// Identity & Role
SystemPrompt *string `json:"system_prompt,omitempty"` // System prompt
RoleID *string `json:"role_id,omitempty"` // Role within team
ManagerID *string `json:"manager_id,omitempty"` // Direct manager user_id
// Status
Status *string `json:"status,omitempty"` // Member status
RobotStatus *string `json:"robot_status,omitempty"` // Robot status
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode
// Communication
RobotEmail *string `json:"robot_email,omitempty"` // Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules
// Capabilities
RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON
Agents interface{} `json:"agents,omitempty"` // Accessible agents
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers
LanguageModel *string `json:"language_model,omitempty"` // Language model name
// Limits
CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
// Auth scope (optional, used by OpenAPI layer via WithUpdateScope)
AuthScope *AuthScope `json:"auth_scope,omitempty"`
}
// RobotResponse - response containing robot details for API
type RobotResponse struct {
// Basic
ID int64 `json:"id,omitempty"`
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
Status string `json:"status"`
RobotStatus string `json:"robot_status"`
AutonomousMode bool `json:"autonomous_mode"`
// Profile
DisplayName string `json:"display_name"`
Bio string `json:"bio,omitempty"`
Avatar string `json:"avatar,omitempty"`
// Identity & Role
SystemPrompt string `json:"system_prompt,omitempty"`
RoleID string `json:"role_id,omitempty"`
ManagerID string `json:"manager_id,omitempty"`
// Communication
RobotEmail string `json:"robot_email,omitempty"`
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"`
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"`
// Capabilities
RobotConfig interface{} `json:"robot_config,omitempty"`
Agents interface{} `json:"agents,omitempty"`
MCPServers interface{} `json:"mcp_servers,omitempty"`
LanguageModel string `json:"language_model,omitempty"`
// Limits
CostLimit float64 `json:"cost_limit,omitempty"`
// Ownership & Audit
InvitedBy string `json:"invited_by,omitempty"`
JoinedAt *time.Time `json:"joined_at,omitempty"`
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id for permission check
YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for permission check
// Timestamps
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// ==================== Helper Functions ====================
// applyDefaults applies default values to ListQuery

View file

@ -18,10 +18,12 @@ var memberFields = []interface{}{
"member_id",
"team_id",
"display_name",
"bio",
"system_prompt",
"robot_status",
"autonomous_mode",
"robot_config",
"robot_email",
}
// SetMemberModel sets the member model name

View file

@ -9,8 +9,7 @@ import (
)
// Smoke tests to verify basic flow works
// These tests use SkipJobIntegration=true to avoid DB dependencies
// Real integration tests are in manager_test.go and job_test.go
// Real integration tests are in manager_test.go
func TestExecutorSmoke(t *testing.T) {
exec := NewDryRunWithDelay(0)

View file

@ -5,18 +5,18 @@ import (
"sync/atomic"
"time"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/robot/executor/types"
"github.com/yaoapp/yao/agent/robot/job"
"github.com/yaoapp/yao/agent/robot/store"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/robot/utils"
)
// Executor implements the standard executor with real Agent calls
// This is the production executor that:
// - Creates Job records for tracking
// - Persists execution history to database
// - Calls real Agents via Assistant.Stream()
// - Logs phase transitions and errors
// - Logs phase transitions and errors using kun/log
type Executor struct {
config types.Config
store *store.ExecutionStore
@ -47,36 +47,22 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
return nil, fmt.Errorf("robot cannot be nil")
}
var exec *robottypes.Execution
var err error
// Determine starting phase based on trigger type
startPhaseIndex := 0
if trigger == robottypes.TriggerHuman || trigger == robottypes.TriggerEvent {
startPhaseIndex = 1 // Skip P0 (Inspiration)
}
// Create execution with Job integration
if !e.config.SkipJobIntegration {
exec, err = job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: trigger,
Input: types.BuildTriggerInput(trigger, data),
})
if err != nil {
return nil, fmt.Errorf("failed to create execution: %w", err)
}
} else {
exec = &robottypes.Execution{
ID: fmt.Sprintf("exec_%d", time.Now().UnixNano()),
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: trigger,
StartTime: time.Now(),
Status: robottypes.ExecPending,
Phase: robottypes.AllPhases[startPhaseIndex],
Input: types.BuildTriggerInput(trigger, data),
}
// Create execution (Job system removed, using ExecutionStore only)
exec := &robottypes.Execution{
ID: utils.NewID(),
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: trigger,
StartTime: time.Now(),
Status: robottypes.ExecPending,
Phase: robottypes.AllPhases[startPhaseIndex],
Input: types.BuildTriggerInput(trigger, data),
}
// Set robot reference for phase methods
@ -88,17 +74,20 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
record := store.FromExecution(exec)
if err := e.store.Save(ctx.Context, record); err != nil {
// Log warning but don't fail execution
if !e.config.SkipJobIntegration {
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist execution record: %v", err))
}
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
"error": err,
}).Warn("Failed to persist execution record: %v", err)
}
}
// Acquire execution slot
if !robot.TryAcquireSlot(exec) {
if !e.config.SkipJobIntegration && exec.JobID != "" {
_ = job.FailExecution(ctx, exec, robottypes.ErrQuotaExceeded)
}
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
}).Warn("Execution quota exceeded")
return nil, robottypes.ErrQuotaExceeded
}
defer robot.RemoveExecution(exec.ID)
@ -118,17 +107,19 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
// Update status to running
exec.Status = robottypes.ExecRunning
if !e.config.SkipJobIntegration {
if err := job.UpdateStatus(ctx, exec, robottypes.ExecRunning); err != nil {
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err))
}
}
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
"trigger_type": string(exec.TriggerType),
}).Info("Execution started")
// Persist running status
if !e.config.SkipPersistence && e.store != nil {
if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecRunning, ""); err != nil {
if !e.config.SkipJobIntegration {
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist running status: %v", err))
}
log.With(log.F{
"execution_id": exec.ID,
"error": err,
}).Warn("Failed to persist running status: %v", err)
}
}
@ -136,9 +127,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
exec.Status = robottypes.ExecFailed
exec.Error = "simulated failure"
if !e.config.SkipJobIntegration {
_ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure"))
}
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
}).Warn("Simulated failure triggered")
// Persist failed status
if !e.config.SkipPersistence && e.store != nil {
_ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, "simulated failure")
@ -152,9 +144,12 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
if err := e.runPhase(ctx, exec, phase, data); err != nil {
exec.Status = robottypes.ExecFailed
exec.Error = err.Error()
if !e.config.SkipJobIntegration {
_ = job.FailExecution(ctx, exec, err)
}
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
"phase": string(phase),
"error": err.Error(),
}).Error("Phase execution failed: %v", err)
// Persist failed status
if !e.config.SkipPersistence && e.store != nil {
_ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, err.Error())
@ -168,17 +163,20 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
now := time.Now()
exec.EndTime = &now
if !e.config.SkipJobIntegration {
if err := job.CompleteExecution(ctx, exec); err != nil {
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err))
}
}
duration := now.Sub(exec.StartTime)
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
"duration_ms": duration.Milliseconds(),
}).Info("Execution completed successfully")
// Persist completed status
if !e.config.SkipPersistence && e.store != nil {
if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecCompleted, ""); err != nil {
if !e.config.SkipJobIntegration {
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist completed status: %v", err))
}
log.With(log.F{
"execution_id": exec.ID,
"error": err,
}).Warn("Failed to persist completed status: %v", err)
}
}
@ -189,11 +187,11 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, phase robottypes.Phase, data interface{}) error {
exec.Phase = phase
if !e.config.SkipJobIntegration {
if err := job.UpdatePhase(ctx, exec, phase); err != nil {
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update phase to %s: %v", phase, err))
}
}
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
"phase": string(phase),
}).Info("Phase started: %s", phase)
if e.config.OnPhaseStart != nil {
e.config.OnPhaseStart(phase)
@ -219,9 +217,12 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
}
if err != nil {
if !e.config.SkipJobIntegration {
_ = job.LogPhaseError(ctx, exec, phase, err)
}
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
"phase": string(phase),
"error": err.Error(),
}).Error("Phase failed: %s - %v", phase, err)
return err
}
@ -231,9 +232,11 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
if phaseData != nil {
if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, phaseData); err != nil {
// Log warning but don't fail execution
if !e.config.SkipJobIntegration {
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist phase %s data: %v", phase, err))
}
log.With(log.F{
"execution_id": exec.ID,
"phase": string(phase),
"error": err,
}).Warn("Failed to persist phase %s data: %v", phase, err)
}
}
}
@ -242,10 +245,13 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
e.config.OnPhaseEnd(phase)
}
if !e.config.SkipJobIntegration {
phaseDuration := time.Since(phaseStart).Milliseconds()
_ = job.LogPhaseEnd(ctx, exec, phase, phaseDuration)
}
phaseDuration := time.Since(phaseStart).Milliseconds()
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
"phase": string(phase),
"duration_ms": phaseDuration,
}).Info("Phase completed: %s (took %dms)", phase, phaseDuration)
return nil
}

View file

@ -34,10 +34,9 @@ func TestExecutorPersistence(t *testing.T) {
robot := createPersistenceTestRobot("member_persist_001", "team_persist_001")
// Create executor with persistence enabled but skip job integration
// Create executor with persistence enabled
e := standard.NewWithConfig(types.Config{
SkipJobIntegration: true,
SkipPersistence: false,
SkipPersistence: false,
})
// Execute with simulated failure to ensure we get a result
@ -71,8 +70,7 @@ func TestExecutorPersistence(t *testing.T) {
robot := createPersistenceTestRobot("member_persist_002", "team_persist_002")
e := standard.NewWithConfig(types.Config{
SkipJobIntegration: true,
SkipPersistence: false,
SkipPersistence: false,
})
// Execute with simulated failure
@ -104,8 +102,7 @@ func TestExecutorPersistence(t *testing.T) {
// Create executor with persistence disabled
e := standard.NewWithConfig(types.Config{
SkipJobIntegration: true,
SkipPersistence: true,
SkipPersistence: true,
})
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure")

View file

@ -50,9 +50,6 @@ type PhaseExecutor interface {
// Config holds common executor configuration
type Config struct {
// SkipJobIntegration skips job system integration (for testing)
SkipJobIntegration bool
// SkipPersistence skips execution record persistence (for testing)
SkipPersistence bool

View file

@ -1,433 +0,0 @@
package job
import (
"encoding/json"
"fmt"
"time"
"github.com/yaoapp/gou/model"
yaojob "github.com/yaoapp/yao/job"
"github.com/yaoapp/yao/agent/robot/types"
)
// CreateOptions holds options for creating a new execution
type CreateOptions struct {
Robot *types.Robot // Required: the robot to execute
TriggerType types.TriggerType // Required: clock | human | event
Input *types.TriggerInput // Optional: trigger input data
// Optional fields for future extension
Priority int // Execution priority (higher = more important)
TimeoutSeconds *int // Execution timeout
ParentExecutionID string // Parent execution ID for sub-tasks
ScheduledAt *time.Time // Scheduled execution time (for delayed execution)
Metadata map[string]interface{} // Custom metadata
}
// Validate validates the CreateOptions
func (o *CreateOptions) Validate() error {
if o.Robot == nil {
return fmt.Errorf("robot is required")
}
if o.TriggerType == "" {
return fmt.Errorf("trigger type is required")
}
return nil
}
// CreateExecution creates a new execution record in the job system
// This creates both the robot Execution and the corresponding job.Execution
func CreateExecution(ctx *types.Context, opts *CreateOptions) (*types.Execution, error) {
if opts == nil {
return nil, fmt.Errorf("options is nil")
}
if err := opts.Validate(); err != nil {
return nil, err
}
robot := opts.Robot
triggerType := opts.TriggerType
// Create job for this execution (returns jobID and execID)
jobID, execID, err := Create(ctx, &Options{
Robot: robot,
TriggerType: triggerType,
Priority: opts.Priority,
Metadata: opts.Metadata,
})
if err != nil {
return nil, fmt.Errorf("failed to create job: %w", err)
}
// Determine starting phase based on trigger type
// Clock trigger starts from P0 (Inspiration)
// Human/Event triggers skip P0 and start from P1 (Goals)
startPhase := types.PhaseInspiration
if triggerType == types.TriggerHuman || triggerType == types.TriggerEvent {
startPhase = types.PhaseGoals
}
// Create robot execution
exec := &types.Execution{
ID: execID,
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: triggerType,
StartTime: time.Now(),
Status: types.ExecPending,
Phase: startPhase,
Input: opts.Input,
JobID: jobID,
}
// Build trigger context for job execution
triggerContext, _ := json.Marshal(map[string]interface{}{
"trigger_type": string(triggerType),
"member_id": robot.MemberID,
"team_id": robot.TeamID,
})
triggerContextRaw := json.RawMessage(triggerContext)
// Map trigger type to trigger category
// TriggerCategory ENUM: manual, scheduled, event, api, system, dependency
triggerCategory := mapTriggerTypeToCategory(triggerType)
triggerSource := string(triggerType) // Store original trigger type as source
// Create job execution record
jobExec := &yaojob.Execution{
ExecutionID: execID,
JobID: jobID,
Status: "queued",
TriggerCategory: triggerCategory,
TriggerSource: &triggerSource,
TriggerContext: &triggerContextRaw,
Progress: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Apply optional fields
if opts.TimeoutSeconds != nil {
jobExec.TimeoutSeconds = opts.TimeoutSeconds
}
if opts.ParentExecutionID != "" {
jobExec.ParentExecutionID = &opts.ParentExecutionID
}
if opts.ScheduledAt != nil {
jobExec.ScheduledAt = opts.ScheduledAt
}
if opts.Priority > 0 {
jobExec.ExecutionOptions = &yaojob.ExecutionOptions{
Priority: opts.Priority,
}
}
if err := yaojob.SaveExecution(jobExec); err != nil {
return nil, fmt.Errorf("failed to save job execution: %w", err)
}
// Note: Job status is automatically updated by yaojob.SaveExecution -> updateJobProgress
// No need to manually set job status here
return exec, nil
}
// UpdatePhase updates the execution phase in the job system
func UpdatePhase(ctx *types.Context, exec *types.Execution, phase types.Phase) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
exec.Phase = phase
// Update job
if err := Update(ctx, exec); err != nil {
return fmt.Errorf("failed to update job: %w", err)
}
// Update job execution progress
progress := phaseToProgress(phase)
if err := updateExecutionProgress(exec.ID, progress, string(phase)); err != nil {
return fmt.Errorf("failed to update execution progress: %w", err)
}
// Log phase transition (ignore error, non-critical)
_ = LogPhaseStart(ctx, exec, phase)
return nil
}
// UpdateStatus updates the execution status in the job system
func UpdateStatus(ctx *types.Context, exec *types.Execution, status types.ExecStatus) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
exec.Status = status
// Update job
if err := Update(ctx, exec); err != nil {
return fmt.Errorf("failed to update job: %w", err)
}
// Update job execution status
if err := updateExecutionStatus(exec.ID, status); err != nil {
return fmt.Errorf("failed to update execution status: %w", err)
}
return nil
}
// CompleteExecution marks execution as completed
func CompleteExecution(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
now := time.Now()
exec.EndTime = &now
exec.Status = types.ExecCompleted
// Complete the job
if err := Complete(ctx, exec); err != nil {
return fmt.Errorf("failed to complete job: %w", err)
}
// Update job execution
if err := completeJobExecution(exec.ID, exec.StartTime); err != nil {
return fmt.Errorf("failed to complete job execution: %w", err)
}
// Log completion (ignore error, non-critical)
locale := getLocale(ctx)
var msg string
if isChineseLocale(locale) {
msg = "执行完成"
} else {
msg = "Execution completed successfully"
}
_ = Log(ctx, exec, "info", msg, map[string]interface{}{
"duration_ms": now.Sub(exec.StartTime).Milliseconds(),
})
return nil
}
// FailExecution marks execution as failed
func FailExecution(ctx *types.Context, exec *types.Execution, execErr error) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
now := time.Now()
exec.EndTime = &now
exec.Status = types.ExecFailed
if execErr != nil {
exec.Error = execErr.Error()
}
// Fail the job
if err := Fail(ctx, exec, execErr); err != nil {
return fmt.Errorf("failed to fail job: %w", err)
}
// Update job execution
if err := failJobExecution(exec.ID, execErr, exec.StartTime); err != nil {
return fmt.Errorf("failed to fail job execution: %w", err)
}
// Log failure (ignore error, non-critical)
_ = LogError(ctx, exec, execErr)
return nil
}
// CancelExecution marks execution as cancelled
func CancelExecution(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
now := time.Now()
exec.EndTime = &now
exec.Status = types.ExecCancelled
// Cancel the job
if err := Cancel(ctx, exec); err != nil {
return fmt.Errorf("failed to cancel job: %w", err)
}
// Update job execution
if err := cancelJobExecution(exec.ID, exec.StartTime); err != nil {
return fmt.Errorf("failed to cancel job execution: %w", err)
}
// Log cancellation (ignore error, non-critical)
locale := getLocale(ctx)
var msg string
if isChineseLocale(locale) {
msg = "执行已取消"
} else {
msg = "Execution cancelled"
}
_ = Log(ctx, exec, "info", msg, nil)
return nil
}
// GetExecution retrieves a job execution by ID
func GetExecution(executionID string) (*yaojob.Execution, error) {
if executionID == "" {
return nil, fmt.Errorf("execution ID is empty")
}
return yaojob.GetExecution(executionID, model.QueryParam{})
}
// ListExecutions lists executions for a job
func ListExecutions(jobID string) ([]*yaojob.Execution, error) {
if jobID == "" {
return nil, fmt.Errorf("job ID is empty")
}
return yaojob.GetExecutions(jobID)
}
// phaseToProgress maps phase to progress percentage
func phaseToProgress(phase types.Phase) int {
switch phase {
case types.PhaseInspiration:
return 10
case types.PhaseGoals:
return 25
case types.PhaseTasks:
return 40
case types.PhaseRun:
return 60
case types.PhaseDelivery:
return 80
case types.PhaseLearning:
return 95
default:
return 0
}
}
// updateExecutionProgress updates the job execution progress
// Note: step parameter is kept for future use if yaojob.Execution adds Step field
func updateExecutionProgress(executionID string, progress int, _ string) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
exec.Progress = progress
exec.UpdatedAt = time.Now()
return yaojob.SaveExecution(exec)
}
// updateExecutionStatus updates the job execution status
func updateExecutionStatus(executionID string, status types.ExecStatus) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
exec.Status = mapStatusToJobStatus(status)
exec.UpdatedAt = time.Now()
if status == types.ExecRunning && exec.StartedAt == nil {
now := time.Now()
exec.StartedAt = &now
}
return yaojob.SaveExecution(exec)
}
// completeJobExecution marks job execution as completed
func completeJobExecution(executionID string, startTime time.Time) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
now := time.Now()
exec.Status = "completed"
exec.Progress = 100
exec.EndedAt = &now
exec.UpdatedAt = now
// Calculate duration (handle zero startTime)
if !startTime.IsZero() {
duration := int(now.Sub(startTime).Milliseconds())
exec.Duration = &duration
}
return yaojob.SaveExecution(exec)
}
// failJobExecution marks job execution as failed
func failJobExecution(executionID string, execErr error, startTime time.Time) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
now := time.Now()
exec.Status = "failed"
exec.EndedAt = &now
exec.UpdatedAt = now
// Calculate duration (handle zero startTime)
if !startTime.IsZero() {
duration := int(now.Sub(startTime).Milliseconds())
exec.Duration = &duration
}
// Store error info
if execErr != nil {
errorInfo, _ := json.Marshal(map[string]string{
"message": execErr.Error(),
})
errorInfoRaw := json.RawMessage(errorInfo)
exec.ErrorInfo = &errorInfoRaw
}
return yaojob.SaveExecution(exec)
}
// cancelJobExecution marks job execution as cancelled
func cancelJobExecution(executionID string, startTime time.Time) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
now := time.Now()
exec.Status = "cancelled"
exec.EndedAt = &now
exec.UpdatedAt = now
// Calculate duration (handle zero startTime)
if !startTime.IsZero() {
duration := int(now.Sub(startTime).Milliseconds())
exec.Duration = &duration
}
return yaojob.SaveExecution(exec)
}
// mapTriggerTypeToCategory maps robot TriggerType to job execution TriggerCategory
// TriggerCategory ENUM values: manual, scheduled, event, api, system, dependency
func mapTriggerTypeToCategory(triggerType types.TriggerType) string {
switch triggerType {
case types.TriggerClock:
return "scheduled"
case types.TriggerHuman:
return "manual"
case types.TriggerEvent:
return "event"
default:
return "system"
}
}

View file

@ -1,559 +0,0 @@
package job_test
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/robot/job"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// TestCreateExecution tests creating a new execution
func TestCreateExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("create execution with clock trigger", func(t *testing.T) {
robot := createTestRobot("test_exec_create_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
assert.NotNil(t, exec)
assert.NotEmpty(t, exec.ID)
assert.NotEmpty(t, exec.JobID)
assert.Equal(t, robot.MemberID, exec.MemberID)
assert.Equal(t, robot.TeamID, exec.TeamID)
assert.Equal(t, types.TriggerClock, exec.TriggerType)
assert.Equal(t, types.ExecPending, exec.Status)
// Clock trigger starts from P0 (Inspiration)
assert.Equal(t, types.PhaseInspiration, exec.Phase)
assert.False(t, exec.StartTime.IsZero())
})
t.Run("create execution with human trigger starts from P1", func(t *testing.T) {
robot := createTestRobot("test_exec_create_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerHuman,
})
require.NoError(t, err)
assert.NotNil(t, exec)
// Human trigger skips P0, starts from P1 (Goals)
assert.Equal(t, types.PhaseGoals, exec.Phase)
})
t.Run("create execution with event trigger starts from P1", func(t *testing.T) {
robot := createTestRobot("test_exec_create_003")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerEvent,
})
require.NoError(t, err)
assert.NotNil(t, exec)
// Event trigger skips P0, starts from P1 (Goals)
assert.Equal(t, types.PhaseGoals, exec.Phase)
})
t.Run("create execution with input", func(t *testing.T) {
robot := createTestRobot("test_exec_create_004")
input := &types.TriggerInput{
Action: types.ActionTaskAdd,
UserID: "test_user_001",
}
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerHuman,
Input: input,
})
require.NoError(t, err)
assert.NotNil(t, exec)
assert.NotNil(t, exec.Input)
assert.Equal(t, types.ActionTaskAdd, exec.Input.Action)
})
t.Run("create execution with optional fields", func(t *testing.T) {
robot := createTestRobot("test_exec_create_005")
timeout := 300
scheduledAt := time.Now().Add(1 * time.Hour)
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
Priority: 5,
TimeoutSeconds: &timeout,
ParentExecutionID: "parent_exec_001",
ScheduledAt: &scheduledAt,
Metadata: map[string]interface{}{
"source": "test",
},
})
require.NoError(t, err)
assert.NotNil(t, exec)
})
t.Run("create execution with nil robot returns error", func(t *testing.T) {
_, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: nil,
TriggerType: types.TriggerClock,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "robot is required")
})
t.Run("create execution with empty trigger type returns error", func(t *testing.T) {
robot := createTestRobot("test_exec_create_006")
_, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: "",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "trigger type is required")
})
t.Run("create execution with nil options returns error", func(t *testing.T) {
_, err := job.CreateExecution(ctx, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "options is nil")
})
}
// TestUpdatePhase tests updating execution phase
func TestUpdatePhase(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("update phase successfully", func(t *testing.T) {
robot := createTestRobot("test_phase_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
// Update to Goals phase
err = job.UpdatePhase(ctx, exec, types.PhaseGoals)
require.NoError(t, err)
assert.Equal(t, types.PhaseGoals, exec.Phase)
// Verify job was updated
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, string(types.PhaseGoals), j.Config["current_phase"])
})
t.Run("update through all phases", func(t *testing.T) {
robot := createTestRobot("test_phase_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
phases := []types.Phase{
types.PhaseGoals,
types.PhaseTasks,
types.PhaseRun,
types.PhaseDelivery,
types.PhaseLearning,
}
for _, phase := range phases {
err = job.UpdatePhase(ctx, exec, phase)
require.NoError(t, err)
assert.Equal(t, phase, exec.Phase)
}
})
t.Run("update phase with nil execution returns error", func(t *testing.T) {
err := job.UpdatePhase(ctx, nil, types.PhaseGoals)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid execution")
})
t.Run("update phase with empty execution ID returns error", func(t *testing.T) {
exec := &types.Execution{
ID: "",
JobID: "some_job_id",
}
err := job.UpdatePhase(ctx, exec, types.PhaseGoals)
assert.Error(t, err)
})
t.Run("update phase with empty job ID returns error", func(t *testing.T) {
exec := &types.Execution{
ID: "some_exec_id",
JobID: "",
}
err := job.UpdatePhase(ctx, exec, types.PhaseGoals)
assert.Error(t, err)
})
}
// TestUpdateStatus tests updating execution status
func TestUpdateStatus(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("update status to running", func(t *testing.T) {
robot := createTestRobot("test_status_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.UpdateStatus(ctx, exec, types.ExecRunning)
require.NoError(t, err)
assert.Equal(t, types.ExecRunning, exec.Status)
// Verify job was updated
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, "running", j.Status)
})
t.Run("update status with nil execution returns error", func(t *testing.T) {
err := job.UpdateStatus(ctx, nil, types.ExecRunning)
assert.Error(t, err)
})
}
// TestCompleteExecution tests completing an execution
func TestCompleteExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("complete execution successfully", func(t *testing.T) {
robot := createTestRobot("test_complete_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
// Simulate execution progress
exec.Delivery = &types.DeliveryResult{
RequestID: "test-delivery-001",
Content: &types.DeliveryContent{
Summary: "Test delivery completed",
Body: "# Test Delivery\n\nThis is a test delivery result.",
},
Success: true,
}
err = job.CompleteExecution(ctx, exec)
require.NoError(t, err)
assert.Equal(t, types.ExecCompleted, exec.Status)
assert.NotNil(t, exec.EndTime)
// Verify job was completed
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, "completed", j.Status)
// Verify job execution was updated
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.Equal(t, "completed", jobExec.Status)
assert.Equal(t, 100, jobExec.Progress)
assert.NotNil(t, jobExec.EndedAt)
})
t.Run("complete execution with nil execution returns error", func(t *testing.T) {
err := job.CompleteExecution(ctx, nil)
assert.Error(t, err)
})
}
// TestFailExecution tests failing an execution
func TestFailExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("fail execution with error", func(t *testing.T) {
robot := createTestRobot("test_fail_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
testErr := errors.New("task execution failed")
err = job.FailExecution(ctx, exec, testErr)
require.NoError(t, err)
assert.Equal(t, types.ExecFailed, exec.Status)
assert.NotNil(t, exec.EndTime)
assert.Equal(t, testErr.Error(), exec.Error)
// Verify job was failed
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, "failed", j.Status)
})
t.Run("fail execution without error", func(t *testing.T) {
robot := createTestRobot("test_fail_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.FailExecution(ctx, exec, nil)
require.NoError(t, err)
assert.Equal(t, types.ExecFailed, exec.Status)
assert.Empty(t, exec.Error)
})
}
// TestCancelExecution tests cancelling an execution
func TestCancelExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("cancel execution successfully", func(t *testing.T) {
robot := createTestRobot("test_cancel_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.CancelExecution(ctx, exec)
require.NoError(t, err)
assert.Equal(t, types.ExecCancelled, exec.Status)
assert.NotNil(t, exec.EndTime)
// Verify job was cancelled
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, "cancelled", j.Status)
})
}
// TestGetExecution tests retrieving an execution
func TestGetExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("get existing execution", func(t *testing.T) {
robot := createTestRobot("test_get_exec_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.NotNil(t, jobExec)
assert.Equal(t, exec.ID, jobExec.ExecutionID)
assert.Equal(t, exec.JobID, jobExec.JobID)
})
t.Run("get non-existent execution returns error", func(t *testing.T) {
_, err := job.GetExecution("non_existent_exec_id")
assert.Error(t, err)
})
t.Run("get with empty execution ID returns error", func(t *testing.T) {
_, err := job.GetExecution("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "execution ID is empty")
})
}
// TestListExecutions tests listing executions for a job
func TestListExecutions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("list executions for job", func(t *testing.T) {
robot := createTestRobot("test_list_exec_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
execs, err := job.ListExecutions(exec.JobID)
require.NoError(t, err)
assert.NotEmpty(t, execs)
assert.Equal(t, 1, len(execs))
assert.Equal(t, exec.ID, execs[0].ExecutionID)
})
t.Run("list with empty job ID returns error", func(t *testing.T) {
_, err := job.ListExecutions("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "job ID is empty")
})
}
// TestPhaseToProgress tests phase to progress mapping
func TestPhaseToProgress(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
testCases := []struct {
phase types.Phase
expectedProgress int
}{
{types.PhaseInspiration, 10},
{types.PhaseGoals, 25},
{types.PhaseTasks, 40},
{types.PhaseRun, 60},
{types.PhaseDelivery, 80},
{types.PhaseLearning, 95},
}
for _, tc := range testCases {
t.Run(string(tc.phase), func(t *testing.T) {
robot := createTestRobot("test_progress_" + string(tc.phase))
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.UpdatePhase(ctx, exec, tc.phase)
require.NoError(t, err)
// Verify progress in job execution
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.Equal(t, tc.expectedProgress, jobExec.Progress)
})
}
}
// TestExecutionDuration tests execution duration calculation
func TestExecutionDuration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("duration calculated on completion", func(t *testing.T) {
robot := createTestRobot("test_duration_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
// Wait a bit to ensure measurable duration
time.Sleep(50 * time.Millisecond)
err = job.CompleteExecution(ctx, exec)
require.NoError(t, err)
// Verify duration was calculated
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.NotNil(t, jobExec.Duration)
assert.Greater(t, *jobExec.Duration, 0)
})
t.Run("duration calculated on failure", func(t *testing.T) {
robot := createTestRobot("test_duration_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
err = job.FailExecution(ctx, exec, errors.New("test error"))
require.NoError(t, err)
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.NotNil(t, jobExec.Duration)
assert.Greater(t, *jobExec.Duration, 0)
})
}

View file

@ -1,365 +0,0 @@
package job
import (
"fmt"
"strings"
gonanoid "github.com/matoous/go-nanoid/v2"
yaojob "github.com/yaoapp/yao/job"
"github.com/yaoapp/yao/agent/robot/types"
)
// CategoryID is the job category for robot executions
const CategoryID = "autonomous_robot"
// JobIDPrefix is the prefix for robot job IDs
const JobIDPrefix = "robot_exec_"
// Options holds options for creating a new job
type Options struct {
Robot *types.Robot // Required: the robot to execute
TriggerType types.TriggerType // Required: clock | human | event
// Optional fields for future extension
Priority int // Job priority (higher = more important)
MaxRetryCount int // Max retry count on failure
DefaultTimeout *int // Default execution timeout in seconds
Metadata map[string]interface{} // Custom metadata stored in job config
}
// Validate validates the Options
func (o *Options) Validate() error {
if o.Robot == nil {
return fmt.Errorf("robot is required")
}
if o.TriggerType == "" {
return fmt.Errorf("trigger type is required")
}
return nil
}
// Create creates a new job for robot execution
// Returns the job ID (format: robot_exec_{execID}) and the generated execution ID
func Create(ctx *types.Context, opts *Options) (jobID string, execID string, err error) {
if opts == nil {
return "", "", fmt.Errorf("options is nil")
}
if err := opts.Validate(); err != nil {
return "", "", err
}
robot := opts.Robot
triggerType := opts.TriggerType
// Generate execution ID
execID, err = gonanoid.New()
if err != nil {
return "", "", fmt.Errorf("failed to generate execution ID: %w", err)
}
// Create job ID: robot_exec_{execID}
jobID = JobIDPrefix + execID
// Get locale from context
locale := getLocale(ctx)
// Build job name based on locale
// Use robot display name for better readability in Activity Monitor
displayName := robot.DisplayName
if displayName == "" {
displayName = robot.MemberID
}
name := buildJobName(locale, triggerType, displayName)
// Build job config
jobConfig := map[string]interface{}{
"member_id": robot.MemberID,
"team_id": robot.TeamID,
"trigger_type": string(triggerType),
"exec_id": execID,
"display_name": displayName,
}
// Merge custom metadata into config
if opts.Metadata != nil {
for k, v := range opts.Metadata {
jobConfig[k] = v
}
}
// Build job params
jobParams := map[string]interface{}{
"job_id": jobID,
"category_name": getCategoryName(locale),
"name": name,
"config": jobConfig,
}
// Apply optional fields
if opts.Priority > 0 {
jobParams["priority"] = opts.Priority
}
if opts.MaxRetryCount > 0 {
jobParams["max_retry_count"] = opts.MaxRetryCount
}
if opts.DefaultTimeout != nil {
jobParams["default_timeout"] = *opts.DefaultTimeout
}
// Create job using yao/job package
j, err := yaojob.Once(yaojob.GOROUTINE, jobParams)
if err != nil {
return "", "", fmt.Errorf("failed to create job: %w", err)
}
// Save job to database
if err := yaojob.SaveJob(j); err != nil {
return "", "", fmt.Errorf("failed to save job: %w", err)
}
return jobID, execID, nil
}
// Get retrieves a job by job ID
func Get(jobID string) (*yaojob.Job, error) {
if jobID == "" {
return nil, fmt.Errorf("job ID is empty")
}
return yaojob.GetJob(jobID)
}
// Update updates job status and phase
func Update(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing job ID")
}
j, err := yaojob.GetJob(exec.JobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
// Map robot status to job status
jobStatus := mapStatusToJobStatus(exec.Status)
j.Status = jobStatus
// Update config with current phase
if j.Config == nil {
j.Config = make(map[string]interface{})
}
j.Config["current_phase"] = string(exec.Phase)
j.Config["current_status"] = string(exec.Status)
if err := yaojob.SaveJob(j); err != nil {
return fmt.Errorf("failed to update job: %w", err)
}
return nil
}
// Complete marks job as completed
func Complete(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing job ID")
}
j, err := yaojob.GetJob(exec.JobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
j.Status = "completed"
// Update config with final state
if j.Config == nil {
j.Config = make(map[string]interface{})
}
j.Config["current_phase"] = string(types.PhaseLearning)
j.Config["current_status"] = string(types.ExecCompleted)
if exec.Delivery != nil {
j.Config["delivery_success"] = exec.Delivery.Success
}
if err := yaojob.SaveJob(j); err != nil {
return fmt.Errorf("failed to complete job: %w", err)
}
return nil
}
// Fail marks job as failed
func Fail(ctx *types.Context, exec *types.Execution, execErr error) error {
if exec == nil || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing job ID")
}
j, err := yaojob.GetJob(exec.JobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
j.Status = "failed"
// Update config with error info
if j.Config == nil {
j.Config = make(map[string]interface{})
}
j.Config["current_phase"] = string(exec.Phase)
j.Config["current_status"] = string(types.ExecFailed)
if execErr != nil {
j.Config["error"] = execErr.Error()
}
if err := yaojob.SaveJob(j); err != nil {
return fmt.Errorf("failed to fail job: %w", err)
}
return nil
}
// Cancel marks job as cancelled
func Cancel(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing job ID")
}
j, err := yaojob.GetJob(exec.JobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
j.Status = "cancelled"
// Update config with cancelled state
if j.Config == nil {
j.Config = make(map[string]interface{})
}
j.Config["current_phase"] = string(exec.Phase)
j.Config["current_status"] = string(types.ExecCancelled)
if err := yaojob.SaveJob(j); err != nil {
return fmt.Errorf("failed to cancel job: %w", err)
}
return nil
}
// mapStatusToJobStatus maps robot ExecStatus to job status string
// Job model ENUM values: draft, ready, queued, running, paused, completed, failed, cancelled, disabled
func mapStatusToJobStatus(status types.ExecStatus) string {
switch status {
case types.ExecPending:
return "queued"
case types.ExecRunning:
return "running"
case types.ExecCompleted:
return "completed"
case types.ExecFailed:
return "failed"
case types.ExecCancelled:
return "cancelled"
default:
return "draft"
}
}
// getLocale returns the locale from context, defaults to "en-US"
func getLocale(ctx *types.Context) string {
if ctx == nil || ctx.Locale == "" {
return "en-US"
}
return ctx.Locale
}
// isChineseLocale checks if the locale is Chinese
func isChineseLocale(locale string) bool {
return strings.HasPrefix(strings.ToLower(locale), "zh")
}
// buildJobName builds the job name based on locale
func buildJobName(locale string, triggerType types.TriggerType, displayName string) string {
var name string
if isChineseLocale(locale) {
name = fmt.Sprintf("机器人执行 - %s", getTriggerTypeName(locale, triggerType))
} else {
name = fmt.Sprintf("Robot Execution - %s", getTriggerTypeName(locale, triggerType))
}
if displayName != "" {
name = fmt.Sprintf("%s (%s)", name, displayName)
}
return name
}
// getCategoryName returns the category name based on locale
func getCategoryName(locale string) string {
if isChineseLocale(locale) {
return "自主机器人"
}
return "Autonomous Robot"
}
// getTriggerTypeName returns the trigger type name based on locale
func getTriggerTypeName(locale string, triggerType types.TriggerType) string {
if isChineseLocale(locale) {
switch triggerType {
case types.TriggerClock:
return "定时触发"
case types.TriggerHuman:
return "人工触发"
case types.TriggerEvent:
return "事件触发"
default:
return string(triggerType)
}
}
switch triggerType {
case types.TriggerClock:
return "Clock"
case types.TriggerHuman:
return "Human"
case types.TriggerEvent:
return "Event"
default:
return string(triggerType)
}
}
// getPhaseName returns the phase name based on locale
func getPhaseName(locale string, phase types.Phase) string {
if isChineseLocale(locale) {
switch phase {
case types.PhaseInspiration:
return "灵感收集"
case types.PhaseGoals:
return "目标生成"
case types.PhaseTasks:
return "任务规划"
case types.PhaseRun:
return "任务执行"
case types.PhaseDelivery:
return "结果交付"
case types.PhaseLearning:
return "学习总结"
default:
return string(phase)
}
}
switch phase {
case types.PhaseInspiration:
return "Inspiration"
case types.PhaseGoals:
return "Goals"
case types.PhaseTasks:
return "Tasks"
case types.PhaseRun:
return "Run"
case types.PhaseDelivery:
return "Delivery"
case types.PhaseLearning:
return "Learning"
default:
return string(phase)
}
}

View file

@ -1,508 +0,0 @@
package job_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/robot/job"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// TestJobCreate tests creating a new job
func TestJobCreate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("create job with clock trigger", func(t *testing.T) {
robot := createTestRobot("test_job_create_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
assert.NotEmpty(t, jobID)
assert.NotEmpty(t, execID)
assert.Contains(t, jobID, job.JobIDPrefix)
assert.Contains(t, jobID, execID)
// Verify job was created in database
j, err := job.Get(jobID)
require.NoError(t, err)
assert.NotNil(t, j)
assert.Equal(t, jobID, j.JobID)
assert.Equal(t, robot.MemberID, j.Config["member_id"])
assert.Equal(t, robot.TeamID, j.Config["team_id"])
assert.Equal(t, string(types.TriggerClock), j.Config["trigger_type"])
})
t.Run("create job with human trigger", func(t *testing.T) {
robot := createTestRobot("test_job_create_002")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerHuman,
})
require.NoError(t, err)
assert.NotEmpty(t, jobID)
assert.NotEmpty(t, execID)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, string(types.TriggerHuman), j.Config["trigger_type"])
})
t.Run("create job with event trigger", func(t *testing.T) {
robot := createTestRobot("test_job_create_003")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerEvent,
})
require.NoError(t, err)
assert.NotEmpty(t, jobID)
assert.NotEmpty(t, execID)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, string(types.TriggerEvent), j.Config["trigger_type"])
})
t.Run("create job with priority and metadata", func(t *testing.T) {
robot := createTestRobot("test_job_create_004")
jobID, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
Priority: 10,
Metadata: map[string]interface{}{
"custom_key": "custom_value",
},
})
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, 10, j.Priority)
assert.Equal(t, "custom_value", j.Config["custom_key"])
})
t.Run("create job with nil robot returns error", func(t *testing.T) {
_, _, err := job.Create(ctx, &job.Options{
Robot: nil,
TriggerType: types.TriggerClock,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "robot is required")
})
t.Run("create job with empty trigger type returns error", func(t *testing.T) {
robot := createTestRobot("test_job_create_005")
_, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: "",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "trigger type is required")
})
t.Run("create job with nil options returns error", func(t *testing.T) {
_, _, err := job.Create(ctx, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "options is nil")
})
}
// TestJobGet tests retrieving a job by ID
func TestJobGet(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("get existing job", func(t *testing.T) {
robot := createTestRobot("test_job_get_001")
jobID, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.NotNil(t, j)
assert.Equal(t, jobID, j.JobID)
})
t.Run("get non-existent job returns error", func(t *testing.T) {
_, err := job.Get("non_existent_job_id")
assert.Error(t, err)
})
t.Run("get with empty job ID returns error", func(t *testing.T) {
_, err := job.Get("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "job ID is empty")
})
}
// TestJobUpdate tests updating job status and phase
func TestJobUpdate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("update job status and phase", func(t *testing.T) {
robot := createTestRobot("test_job_update_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Status: types.ExecRunning,
Phase: types.PhaseGoals,
}
err = job.Update(ctx, exec)
require.NoError(t, err)
// Verify update
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, "running", j.Status)
assert.Equal(t, string(types.PhaseGoals), j.Config["current_phase"])
assert.Equal(t, string(types.ExecRunning), j.Config["current_status"])
})
t.Run("update with nil execution returns error", func(t *testing.T) {
err := job.Update(ctx, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid execution")
})
t.Run("update with empty job ID returns error", func(t *testing.T) {
exec := &types.Execution{
ID: "some_id",
JobID: "",
Status: types.ExecRunning,
Phase: types.PhaseGoals,
}
err := job.Update(ctx, exec)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid execution")
})
}
// TestJobComplete tests completing a job
func TestJobComplete(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("complete job successfully", func(t *testing.T) {
robot := createTestRobot("test_job_complete_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Delivery: &types.DeliveryResult{
Success: true,
},
}
err = job.Complete(ctx, exec)
require.NoError(t, err)
// Verify completion
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, "completed", j.Status)
assert.Equal(t, string(types.PhaseLearning), j.Config["current_phase"])
assert.Equal(t, string(types.ExecCompleted), j.Config["current_status"])
assert.Equal(t, true, j.Config["delivery_success"])
})
t.Run("complete with nil execution returns error", func(t *testing.T) {
err := job.Complete(ctx, nil)
assert.Error(t, err)
})
}
// TestJobFail tests failing a job
func TestJobFail(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("fail job with error", func(t *testing.T) {
robot := createTestRobot("test_job_fail_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Phase: types.PhaseRun,
}
testErr := assert.AnError
err = job.Fail(ctx, exec, testErr)
require.NoError(t, err)
// Verify failure
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, "failed", j.Status)
assert.Equal(t, string(types.PhaseRun), j.Config["current_phase"])
assert.Equal(t, string(types.ExecFailed), j.Config["current_status"])
assert.NotEmpty(t, j.Config["error"])
})
t.Run("fail job without error message", func(t *testing.T) {
robot := createTestRobot("test_job_fail_002")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Phase: types.PhaseDelivery,
}
err = job.Fail(ctx, exec, nil)
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, "failed", j.Status)
assert.Nil(t, j.Config["error"])
})
}
// TestJobCancel tests cancelling a job
func TestJobCancel(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("cancel job successfully", func(t *testing.T) {
robot := createTestRobot("test_job_cancel_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
// First verify job was created
j, err := job.Get(jobID)
require.NoError(t, err)
t.Logf("Job ID: %d, Status before cancel: %s, Config: %v", j.ID, j.Status, j.Config)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Phase: types.PhaseTasks,
}
err = job.Cancel(ctx, exec)
require.NoError(t, err)
// Verify cancellation - check config since status might not be returned correctly
j, err = job.Get(jobID)
require.NoError(t, err)
t.Logf("Job ID: %d, Status after cancel: %s, Config: %v", j.ID, j.Status, j.Config)
// Check config values which should be correctly updated
assert.Equal(t, string(types.PhaseTasks), j.Config["current_phase"])
assert.Equal(t, string(types.ExecCancelled), j.Config["current_status"])
})
}
// TestJobLocalization tests job name localization
func TestJobLocalization(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("english locale", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "en-US",
}
robot := createTestRobot("test_job_locale_en")
robot.DisplayName = "Sales Bot"
jobID, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Contains(t, j.Name, "Robot Execution")
assert.Contains(t, j.Name, "Clock")
assert.Contains(t, j.Name, "Sales Bot")
})
t.Run("chinese locale", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "zh-CN",
}
robot := createTestRobot("test_job_locale_zh")
robot.DisplayName = "销售机器人"
jobID, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerHuman,
})
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Contains(t, j.Name, "机器人执行")
assert.Contains(t, j.Name, "人工触发")
assert.Contains(t, j.Name, "销售机器人")
})
}
// TestMapStatusToJobStatus tests status mapping via config
func TestMapStatusToJobStatus(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
testCases := []struct {
status types.ExecStatus
expectedStatus string // This is the raw ExecStatus string stored in config["current_status"]
}{
{types.ExecPending, "pending"},
{types.ExecRunning, "running"},
{types.ExecCompleted, "completed"},
{types.ExecFailed, "failed"},
{types.ExecCancelled, "cancelled"},
}
for _, tc := range testCases {
t.Run(string(tc.status), func(t *testing.T) {
robot := createTestRobot("test_status_map_" + string(tc.status))
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Status: tc.status,
Phase: types.PhaseInspiration,
}
err = job.Update(ctx, exec)
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
// Verify status is stored in config (since Job.Status field may not be reliably returned)
assert.Equal(t, tc.expectedStatus, j.Config["current_status"])
})
}
}
// createTestRobot creates a test robot for testing
func createTestRobot(memberID string) *types.Robot {
return &types.Robot{
MemberID: memberID,
TeamID: "test_team_001",
DisplayName: "Test Robot " + memberID,
SystemPrompt: "You are a test robot.",
Status: types.RobotIdle,
AutonomousMode: true,
Config: &types.Config{
Triggers: &types.Triggers{
Clock: &types.TriggerSwitch{Enabled: true},
Intervene: &types.TriggerSwitch{Enabled: true},
Event: &types.TriggerSwitch{Enabled: true},
},
Identity: &types.Identity{
Role: "Test Role",
},
Quota: &types.Quota{
Max: 2,
},
},
}
}
// cleanupTestJobs cleans up test jobs from database
func cleanupTestJobs(t *testing.T) {
// Jobs are auto-cleaned by yao/job package
// This is a placeholder for any additional cleanup
}

View file

@ -1,296 +0,0 @@
package job
import (
"encoding/json"
"fmt"
"time"
yaojob "github.com/yaoapp/yao/job"
"github.com/yaoapp/yao/agent/robot/types"
)
// Log writes a log entry for the execution
func Log(ctx *types.Context, exec *types.Execution, level string, message string, data map[string]interface{}) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
// Build context JSON with execution_id included
if data == nil {
data = make(map[string]interface{})
}
data["execution_id"] = exec.ID
var contextRaw *json.RawMessage
contextBytes, err := json.Marshal(data)
if err == nil {
raw := json.RawMessage(contextBytes)
contextRaw = &raw
}
// Extract step from data if available
var step *string
if s, ok := data["step"].(string); ok {
step = &s
}
logEntry := &yaojob.Log{
JobID: exec.JobID,
Level: level,
Message: message,
Context: contextRaw,
ExecutionID: &exec.ID,
Step: step,
Timestamp: time.Now(),
Sequence: 0,
}
return yaojob.SaveLog(logEntry)
}
// LogPhaseStart logs the start of a phase
func LogPhaseStart(ctx *types.Context, exec *types.Execution, phase types.Phase) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
phaseName := getPhaseName(locale, phase)
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("阶段开始: %s", phaseName)
} else {
message = fmt.Sprintf("Phase started: %s", phaseName)
}
return Log(ctx, exec, "info", message, map[string]interface{}{
"phase": string(phase),
"phase_name": phaseName,
"step": fmt.Sprintf("phase_%s_start", phase),
"event": "phase_start",
})
}
// LogPhaseEnd logs the end of a phase
func LogPhaseEnd(ctx *types.Context, exec *types.Execution, phase types.Phase, durationMs int64) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
phaseName := getPhaseName(locale, phase)
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("阶段完成: %s", phaseName)
} else {
message = fmt.Sprintf("Phase completed: %s", phaseName)
}
return Log(ctx, exec, "info", message, map[string]interface{}{
"phase": string(phase),
"phase_name": phaseName,
"step": fmt.Sprintf("phase_%s_end", phase),
"event": "phase_end",
"duration_ms": durationMs,
})
}
// LogPhaseError logs a phase error
func LogPhaseError(ctx *types.Context, exec *types.Execution, phase types.Phase, err error) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
phaseName := getPhaseName(locale, phase)
errMsg := "unknown error"
if err != nil {
errMsg = err.Error()
}
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("阶段失败: %s - %s", phaseName, errMsg)
} else {
message = fmt.Sprintf("Phase failed: %s - %s", phaseName, errMsg)
}
return Log(ctx, exec, "error", message, map[string]interface{}{
"phase": string(phase),
"phase_name": phaseName,
"step": fmt.Sprintf("phase_%s_error", phase),
"event": "phase_error",
"error": errMsg,
})
}
// LogError logs an error
func LogError(ctx *types.Context, exec *types.Execution, err error) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
errMsg := "unknown error"
if err != nil {
errMsg = err.Error()
}
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("错误: %s", errMsg)
} else {
message = errMsg
}
return Log(ctx, exec, "error", message, map[string]interface{}{
"event": "error",
"error": errMsg,
})
}
// LogInfo logs an info message
func LogInfo(ctx *types.Context, exec *types.Execution, message string) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
return Log(ctx, exec, "info", message, nil)
}
// LogDebug logs a debug message
func LogDebug(ctx *types.Context, exec *types.Execution, message string) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
return Log(ctx, exec, "debug", message, nil)
}
// LogWarn logs a warning message
func LogWarn(ctx *types.Context, exec *types.Execution, message string) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
return Log(ctx, exec, "warning", message, nil)
}
// LogTaskStart logs the start of a task
func LogTaskStart(ctx *types.Context, exec *types.Execution, taskID string, taskOrder int) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("任务开始: %s", taskID)
} else {
message = fmt.Sprintf("Task started: %s", taskID)
}
return Log(ctx, exec, "info", message, map[string]interface{}{
"task_id": taskID,
"task_order": taskOrder,
"step": fmt.Sprintf("task_%d_start", taskOrder),
"event": "task_start",
})
}
// LogTaskEnd logs the end of a task
func LogTaskEnd(ctx *types.Context, exec *types.Execution, taskID string, taskOrder int, success bool, durationMs int64) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
level := "info"
event := "task_success"
var msg string
if isChineseLocale(locale) {
if success {
msg = fmt.Sprintf("任务完成: %s", taskID)
} else {
level = "warning"
event = "task_failed"
msg = fmt.Sprintf("任务失败: %s", taskID)
}
} else {
if success {
msg = fmt.Sprintf("Task completed: %s", taskID)
} else {
level = "warning"
event = "task_failed"
msg = fmt.Sprintf("Task failed: %s", taskID)
}
}
return Log(ctx, exec, level, msg, map[string]interface{}{
"task_id": taskID,
"task_order": taskOrder,
"step": fmt.Sprintf("task_%d_end", taskOrder),
"event": event,
"success": success,
"duration_ms": durationMs,
})
}
// LogDelivery logs delivery result
func LogDelivery(ctx *types.Context, exec *types.Execution, deliveryType string, success bool) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
level := "info"
var msg string
if isChineseLocale(locale) {
if success {
msg = fmt.Sprintf("交付完成: %s", deliveryType)
} else {
level = "warning"
msg = fmt.Sprintf("交付失败: %s", deliveryType)
}
} else {
if success {
msg = fmt.Sprintf("Delivery completed: %s", deliveryType)
} else {
level = "warning"
msg = fmt.Sprintf("Delivery failed: %s", deliveryType)
}
}
return Log(ctx, exec, level, msg, map[string]interface{}{
"delivery_type": deliveryType,
"step": "delivery",
"event": "delivery",
"success": success,
})
}
// LogLearning logs learning result
func LogLearning(ctx *types.Context, exec *types.Execution, entriesCount int) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
var msg string
if isChineseLocale(locale) {
msg = fmt.Sprintf("学习保存: %d 条记录", entriesCount)
} else {
msg = fmt.Sprintf("Learning saved: %d entries", entriesCount)
}
return Log(ctx, exec, "info", msg, map[string]interface{}{
"entries_count": entriesCount,
"step": "learning",
"event": "learning",
})
}

View file

@ -1,771 +0,0 @@
package job_test
import (
"context"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps"
yaojob "github.com/yaoapp/yao/job"
"github.com/yaoapp/yao/agent/robot/job"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// TestLog tests writing log entries
func TestLog(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("write info log", func(t *testing.T) {
robot := createTestRobot("test_log_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.Log(ctx, exec, "info", "Test message", map[string]interface{}{
"key": "value",
})
require.NoError(t, err)
// Verify log was written
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
assert.NotEmpty(t, logs)
found := false
for _, log := range logs {
if log.Message == "Test message" && log.Level == "info" {
found = true
break
}
}
assert.True(t, found, "Log entry should be found")
})
t.Run("write error log", func(t *testing.T) {
robot := createTestRobot("test_log_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.Log(ctx, exec, "error", "Error occurred", nil)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Message == "Error occurred" && log.Level == "error" {
found = true
break
}
}
assert.True(t, found, "Error log entry should be found")
})
t.Run("log with nil execution returns error", func(t *testing.T) {
err := job.Log(ctx, nil, "info", "Test", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid execution")
})
t.Run("log with empty job ID returns error", func(t *testing.T) {
exec := &types.Execution{
ID: "some_id",
JobID: "",
}
err := job.Log(ctx, exec, "info", "Test", nil)
assert.Error(t, err)
})
}
// TestLogPhaseStart tests logging phase start
func TestLogPhaseStart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log phase start in english", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "en-US",
}
robot := createTestRobot("test_phase_log_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseStart(ctx, exec, types.PhaseGoals)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "Phase started") && containsString(log.Message, "Goals") {
found = true
break
}
}
assert.True(t, found, "Phase start log should be found")
})
t.Run("log phase start in chinese", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "zh-CN",
}
robot := createTestRobot("test_phase_log_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseStart(ctx, exec, types.PhaseGoals)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "阶段开始") {
found = true
break
}
}
assert.True(t, found, "Chinese phase start log should be found")
})
t.Run("log phase start with nil execution returns error", func(t *testing.T) {
err := job.LogPhaseStart(ctx, nil, types.PhaseGoals)
assert.Error(t, err)
})
}
// TestLogPhaseEnd tests logging phase end
func TestLogPhaseEnd(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log phase end with duration", func(t *testing.T) {
robot := createTestRobot("test_phase_end_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseEnd(ctx, exec, types.PhaseInspiration, 1500)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && (containsString(log.Message, "Phase completed") || containsString(log.Message, "阶段完成")) {
found = true
break
}
}
assert.True(t, found, "Phase end log should be found")
})
}
// TestLogPhaseError tests logging phase error
func TestLogPhaseError(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log phase error", func(t *testing.T) {
robot := createTestRobot("test_phase_err_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
testErr := errors.New("goal generation failed")
err = job.LogPhaseError(ctx, exec, types.PhaseGoals, testErr)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "error" && containsString(log.Message, "goal generation failed") {
found = true
break
}
}
assert.True(t, found, "Phase error log should be found")
})
t.Run("log phase error with nil error", func(t *testing.T) {
robot := createTestRobot("test_phase_err_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseError(ctx, exec, types.PhaseGoals, nil)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "error" && containsString(log.Message, "unknown error") {
found = true
break
}
}
assert.True(t, found, "Phase error log with unknown error should be found")
})
}
// TestLogError tests logging errors
func TestLogError(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log error", func(t *testing.T) {
robot := createTestRobot("test_error_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
testErr := errors.New("connection timeout")
err = job.LogError(ctx, exec, testErr)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "error" && containsString(log.Message, "connection timeout") {
found = true
break
}
}
assert.True(t, found, "Error log should be found")
})
}
// TestLogInfo tests logging info messages
func TestLogInfo(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log info message", func(t *testing.T) {
robot := createTestRobot("test_info_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogInfo(ctx, exec, "Processing started")
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && log.Message == "Processing started" {
found = true
break
}
}
assert.True(t, found, "Info log should be found")
})
}
// TestLogDebug tests logging debug messages
func TestLogDebug(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log debug message", func(t *testing.T) {
robot := createTestRobot("test_debug_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogDebug(ctx, exec, "Debug info")
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "debug" && log.Message == "Debug info" {
found = true
break
}
}
assert.True(t, found, "Debug log should be found")
})
}
// TestLogWarn tests logging warning messages
func TestLogWarn(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log warning message", func(t *testing.T) {
robot := createTestRobot("test_warn_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogWarn(ctx, exec, "Resource running low")
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "warning" && log.Message == "Resource running low" {
found = true
break
}
}
assert.True(t, found, "Warning log should be found")
})
}
// TestLogTaskStart tests logging task start
func TestLogTaskStart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log task start", func(t *testing.T) {
robot := createTestRobot("test_task_start_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogTaskStart(ctx, exec, "task_001", 1)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "task_001") {
found = true
break
}
}
assert.True(t, found, "Task start log should be found")
})
}
// TestLogTaskEnd tests logging task end
func TestLogTaskEnd(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log task end success", func(t *testing.T) {
robot := createTestRobot("test_task_end_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogTaskEnd(ctx, exec, "task_001", 1, true, 500)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "task_001") {
found = true
break
}
}
assert.True(t, found, "Task end success log should be found")
})
t.Run("log task end failure", func(t *testing.T) {
robot := createTestRobot("test_task_end_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogTaskEnd(ctx, exec, "task_002", 2, false, 300)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "warning" && containsString(log.Message, "task_002") {
found = true
break
}
}
assert.True(t, found, "Task end failure log should be found")
})
}
// TestLogDelivery tests logging delivery
func TestLogDelivery(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log delivery success", func(t *testing.T) {
robot := createTestRobot("test_delivery_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogDelivery(ctx, exec, "email", true)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "email") {
found = true
break
}
}
assert.True(t, found, "Delivery success log should be found")
})
t.Run("log delivery failure", func(t *testing.T) {
robot := createTestRobot("test_delivery_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogDelivery(ctx, exec, "webhook", false)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "warning" && containsString(log.Message, "webhook") {
found = true
break
}
}
assert.True(t, found, "Delivery failure log should be found")
})
}
// TestLogLearning tests logging learning
func TestLogLearning(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log learning entries", func(t *testing.T) {
robot := createTestRobot("test_learning_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogLearning(ctx, exec, 5)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && (containsString(log.Message, "5") || containsString(log.Message, "Learning")) {
found = true
break
}
}
assert.True(t, found, "Learning log should be found")
})
}
// TestLogLocalization tests log message localization
func TestLogLocalization(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("english locale messages", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "en-US",
}
robot := createTestRobot("test_locale_en_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseStart(ctx, exec, types.PhaseRun)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if containsString(log.Message, "Phase started") && containsString(log.Message, "Run") {
found = true
break
}
}
assert.True(t, found, "English phase start message should be found")
})
t.Run("chinese locale messages", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "zh-CN",
}
robot := createTestRobot("test_locale_zh_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseStart(ctx, exec, types.PhaseRun)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if containsString(log.Message, "阶段开始") && containsString(log.Message, "任务执行") {
found = true
break
}
}
assert.True(t, found, "Chinese phase start message should be found")
})
}
// getJobLogs retrieves logs for a job
func getJobLogs(jobID string) ([]*yaojob.Log, error) {
result, err := yaojob.ListLogs(jobID, model.QueryParam{}, 1, 100)
if err != nil {
return nil, err
}
data, exists := result["data"]
if !exists {
return nil, fmt.Errorf("ListLogs result missing 'data' field")
}
// Handle nil data
if data == nil {
return []*yaojob.Log{}, nil
}
// Handle different data types from ListLogs
var logs []*yaojob.Log
switch typedData := data.(type) {
case []maps.MapStrAny:
for _, item := range typedData {
log := &yaojob.Log{}
if msg, ok := item["message"].(string); ok {
log.Message = msg
}
if level, ok := item["level"].(string); ok {
log.Level = level
}
if jid, ok := item["job_id"].(string); ok {
log.JobID = jid
}
logs = append(logs, log)
}
case []map[string]interface{}:
for _, item := range typedData {
log := &yaojob.Log{}
if msg, ok := item["message"].(string); ok {
log.Message = msg
}
if level, ok := item["level"].(string); ok {
log.Level = level
}
if jid, ok := item["job_id"].(string); ok {
log.JobID = jid
}
logs = append(logs, log)
}
case []interface{}:
// Handle generic []interface{} which may contain map types
for _, rawItem := range typedData {
log := &yaojob.Log{}
switch item := rawItem.(type) {
case maps.MapStrAny:
if msg, ok := item["message"].(string); ok {
log.Message = msg
}
if level, ok := item["level"].(string); ok {
log.Level = level
}
if jid, ok := item["job_id"].(string); ok {
log.JobID = jid
}
case map[string]interface{}:
if msg, ok := item["message"].(string); ok {
log.Message = msg
}
if level, ok := item["level"].(string); ok {
log.Level = level
}
if jid, ok := item["job_id"].(string); ok {
log.JobID = jid
}
default:
return nil, fmt.Errorf("unexpected item type in data array: %T", rawItem)
}
logs = append(logs, log)
}
default:
return nil, fmt.Errorf("unexpected data type from ListLogs: %T (value: %v)", data, data)
}
return logs, nil
}
// containsString checks if a string contains a substring
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View file

@ -381,6 +381,7 @@ func (m *Manager) matchesDay(clock *types.Clock, now time.Time) bool {
// TriggerManual manually triggers a robot execution (for testing or API calls)
// This bypasses clock checking and directly submits to pool
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error) {
m.mu.RLock()
if !m.started {
@ -389,10 +390,10 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
}
m.mu.RUnlock()
// Get robot from cache
robot := m.cache.Get(memberID)
if robot == nil {
return "", types.ErrRobotNotFound
// Get robot from cache, or lazy-load if not found
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, memberID)
if err != nil {
return "", err
}
// Check robot status
@ -410,9 +411,18 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
// Submit to pool
execID, err := m.pool.Submit(ctx, robot, trigger, data)
if err != nil {
// If lazy-loaded and submission failed, remove from cache
if lazyLoaded {
m.cache.Remove(memberID)
}
return "", err
}
// For lazy-loaded robots, schedule cleanup after execution completes
if lazyLoaded {
m.scheduleCleanup(robot)
}
return execID, nil
}
@ -420,6 +430,7 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
// Intervene processes a human intervention request
// Human intervention skips P0 (inspiration) and goes directly to P1 (goals)
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
m.mu.RLock()
if !m.started {
@ -433,10 +444,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
return nil, err
}
// Get robot from cache
robot := m.cache.Get(req.MemberID)
if robot == nil {
return nil, types.ErrRobotNotFound
// Get robot from cache, or lazy-load if not found
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
if err != nil {
return nil, err
}
// Check robot status
@ -460,6 +471,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
// Handle plan.add action - schedule for later
if req.Action == types.ActionPlanAdd && req.PlanTime != nil {
// If lazy-loaded but not executing, remove immediately
if lazyLoaded {
m.cache.Remove(req.MemberID)
}
// TODO: Add to plan queue (Phase 11.3)
return &types.ExecutionResult{
Status: types.ExecPending,
@ -473,12 +488,21 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
// Submit to pool with executor mode
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerHuman, triggerInput, executorMode)
if err != nil {
// If lazy-loaded and submission failed, remove from cache
if lazyLoaded {
m.cache.Remove(req.MemberID)
}
return nil, err
}
// Track execution for pause/resume/stop
m.execController.Track(execID, req.MemberID, req.TeamID)
// For lazy-loaded robots, schedule cleanup after execution completes
if lazyLoaded {
m.scheduleCleanup(robot)
}
return &types.ExecutionResult{
ExecutionID: execID,
Status: types.ExecPending,
@ -488,6 +512,7 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
// HandleEvent processes an event trigger request
// Event trigger skips P0 (inspiration) and goes directly to P1 (goals)
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
m.mu.RLock()
if !m.started {
@ -501,10 +526,10 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
return nil, err
}
// Get robot from cache
robot := m.cache.Get(req.MemberID)
if robot == nil {
return nil, types.ErrRobotNotFound
// Get robot from cache, or lazy-load if not found
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
if err != nil {
return nil, err
}
// Check robot status
@ -528,12 +553,21 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
// Submit to pool with executor mode
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerEvent, triggerInput, executorMode)
if err != nil {
// If lazy-loaded and submission failed, remove from cache
if lazyLoaded {
m.cache.Remove(req.MemberID)
}
return nil, err
}
// Track execution for pause/resume/stop
m.execController.Track(execID, req.MemberID, "")
// For lazy-loaded robots, schedule cleanup after execution completes
if lazyLoaded {
m.scheduleCleanup(robot)
}
return &types.ExecutionResult{
ExecutionID: execID,
Status: types.ExecPending,
@ -579,6 +613,70 @@ func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledE
// ==================== Helper Methods ====================
// getOrLoadRobot gets a robot from cache, or lazy-loads from DB if not found
// Returns: robot, wasLazyLoaded, error
func (m *Manager) getOrLoadRobot(ctx *types.Context, memberID string) (*types.Robot, bool, error) {
// Try cache first
robot := m.cache.Get(memberID)
if robot != nil {
return robot, false, nil
}
// Not in cache - lazy load from database
robot, err := m.cache.LoadByID(ctx, memberID)
if err != nil {
return nil, false, err
}
// Add to cache temporarily for execution tracking
m.cache.Add(robot)
// Return with lazyLoaded=true to indicate cleanup needed after execution
return robot, true, nil
}
// scheduleCleanup schedules removal of a lazy-loaded robot after all executions complete
// This runs in a goroutine that monitors the robot's execution count
func (m *Manager) scheduleCleanup(robot *types.Robot) {
go func() {
memberID := robot.MemberID
// Poll every 5 seconds to check if all executions are done
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
// Timeout after 24 hours to prevent memory leaks
timeout := time.After(24 * time.Hour)
for {
select {
case <-timeout:
// Timeout - force cleanup
m.cache.Remove(memberID)
return
case <-ticker.C:
// Check if robot still exists in cache
r := m.cache.Get(memberID)
if r == nil {
// Already removed
return
}
// Check if all executions are done
if r.RunningCount() == 0 {
// Only remove if still non-autonomous
// (user might have changed it during execution)
if !r.AutonomousMode {
m.cache.Remove(memberID)
}
return
}
}
}
}()
}
// resolveExecutorMode determines the executor mode to use
// Priority: request > robot config > default (standard)
func (m *Manager) resolveExecutorMode(requestMode types.ExecutorMode, robot *types.Robot) types.ExecutorMode {

View file

@ -1397,6 +1397,272 @@ func setupTestRobotsWithEventConfig(t *testing.T) {
}
}
// ==================== Lazy Load Tests for Non-Autonomous Robots ====================
// TestManagerLazyLoadNonAutonomous tests that non-autonomous robots are lazy-loaded on demand
// and automatically cleaned up after execution completes
func TestManagerLazyLoadNonAutonomous(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobotsWithNonAutonomous(t)
defer cleanupTestRobots(t)
t.Run("non-autonomous robot not in cache on startup", func(t *testing.T) {
m := manager.New()
err := m.Start()
assert.NoError(t, err)
defer m.Stop()
// Non-autonomous robot should NOT be in cache
robot := m.Cache().Get("robot_test_manager_on_demand")
assert.Nil(t, robot, "Non-autonomous robot should not be pre-loaded into cache")
// Autonomous robot SHOULD be in cache
autoRobot := m.Cache().Get("robot_test_manager_times")
assert.NotNil(t, autoRobot, "Autonomous robot should be in cache")
})
t.Run("TriggerManual lazy-loads non-autonomous robot", func(t *testing.T) {
m := manager.New()
err := m.Start()
assert.NoError(t, err)
defer m.Stop()
ctx := types.NewContext(context.Background(), nil)
// Verify robot is NOT in cache before trigger
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand"))
// Trigger the non-autonomous robot manually
execID, err := m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil)
assert.NoError(t, err)
assert.NotEmpty(t, execID)
// Robot should now be in cache (lazy-loaded)
robot := m.Cache().Get("robot_test_manager_on_demand")
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache")
assert.Equal(t, "robot_test_manager_on_demand", robot.MemberID)
assert.False(t, robot.AutonomousMode)
})
t.Run("Intervene lazy-loads non-autonomous robot", func(t *testing.T) {
m := manager.New()
err := m.Start()
assert.NoError(t, err)
defer m.Stop()
ctx := types.NewContext(context.Background(), nil)
// Verify robot is NOT in cache before trigger
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_intervene"))
// Intervene on the non-autonomous robot
req := &types.InterveneRequest{
TeamID: "team_test_manager",
MemberID: "robot_test_manager_on_demand_intervene",
Action: types.ActionTaskAdd,
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Test lazy load via intervene"},
},
}
result, err := m.Intervene(ctx, req)
assert.NoError(t, err)
assert.NotEmpty(t, result.ExecutionID)
// Robot should now be in cache (lazy-loaded)
robot := m.Cache().Get("robot_test_manager_on_demand_intervene")
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via Intervene")
})
t.Run("HandleEvent lazy-loads non-autonomous robot", func(t *testing.T) {
m := manager.New()
err := m.Start()
assert.NoError(t, err)
defer m.Stop()
ctx := types.NewContext(context.Background(), nil)
// Verify robot is NOT in cache before trigger
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_event"))
// Send event to the non-autonomous robot
req := &types.EventRequest{
MemberID: "robot_test_manager_on_demand_event",
Source: "webhook",
EventType: "data.updated",
Data: map[string]interface{}{"test": true},
}
result, err := m.HandleEvent(ctx, req)
assert.NoError(t, err)
assert.NotEmpty(t, result.ExecutionID)
// Robot should now be in cache (lazy-loaded)
robot := m.Cache().Get("robot_test_manager_on_demand_event")
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via HandleEvent")
})
t.Run("lazy-loaded robot is cleaned up after execution completes", func(t *testing.T) {
m := manager.New()
err := m.Start()
assert.NoError(t, err)
defer m.Stop()
ctx := types.NewContext(context.Background(), nil)
// Trigger the non-autonomous robot
_, err = m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil)
assert.NoError(t, err)
// Robot should be in cache immediately after trigger
robot := m.Cache().Get("robot_test_manager_on_demand")
assert.NotNil(t, robot, "Robot should be in cache after trigger")
// Wait for execution to complete and cleanup to happen
// The stub executor completes quickly, and cleanup runs every 5 seconds
// We wait up to 10 seconds for the cleanup goroutine to remove the robot
var removed bool
for i := 0; i < 20; i++ {
time.Sleep(500 * time.Millisecond)
if m.Cache().Get("robot_test_manager_on_demand") == nil {
removed = true
break
}
}
assert.True(t, removed, "Non-autonomous robot should be removed from cache after execution completes")
})
t.Run("trigger non-existent robot returns error", func(t *testing.T) {
m := manager.New()
err := m.Start()
assert.NoError(t, err)
defer m.Stop()
ctx := types.NewContext(context.Background(), nil)
// Try to trigger a robot that doesn't exist in DB
_, err = m.TriggerManual(ctx, "robot_nonexistent_xyz", types.TriggerHuman, nil)
assert.Error(t, err)
assert.Equal(t, types.ErrRobotNotFound, err)
})
}
// setupTestRobotsWithNonAutonomous creates test robots including non-autonomous ones
func setupTestRobotsWithNonAutonomous(t *testing.T) {
// First setup the autonomous robots
setupTestRobotsWithClockConfig(t)
// Add non-autonomous robots
qb := capsule.Query()
m := model.Select("__yao.member")
tableName := m.MetaData.Table.Name
// Non-autonomous robot 1: for TriggerManual test
robotConfigOnDemand := map[string]interface{}{
"identity": map[string]interface{}{
"role": "On-Demand Robot",
},
"triggers": map[string]interface{}{
"clock": map[string]interface{}{"enabled": false},
"intervene": map[string]interface{}{"enabled": true},
},
"quota": map[string]interface{}{
"max": 2,
"queue": 5,
},
}
configOnDemandJSON, _ := json.Marshal(robotConfigOnDemand)
err := qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_manager_on_demand",
"team_id": "team_test_manager",
"member_type": "robot",
"display_name": "Test On-Demand Robot",
"status": "active",
"role_id": "member",
"autonomous_mode": false, // Non-autonomous!
"robot_status": "idle",
"robot_config": string(configOnDemandJSON),
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_manager_on_demand: %v", err)
}
// Non-autonomous robot 2: for Intervene test
robotConfigOnDemandIntervene := map[string]interface{}{
"identity": map[string]interface{}{
"role": "On-Demand Intervene Robot",
},
"triggers": map[string]interface{}{
"clock": map[string]interface{}{"enabled": false},
"intervene": map[string]interface{}{"enabled": true},
},
"quota": map[string]interface{}{
"max": 2,
},
}
configOnDemandInterveneJSON, _ := json.Marshal(robotConfigOnDemandIntervene)
err = qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_manager_on_demand_intervene",
"team_id": "team_test_manager",
"member_type": "robot",
"display_name": "Test On-Demand Intervene Robot",
"status": "active",
"role_id": "member",
"autonomous_mode": false, // Non-autonomous!
"robot_status": "idle",
"robot_config": string(configOnDemandInterveneJSON),
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_manager_on_demand_intervene: %v", err)
}
// Non-autonomous robot 3: for HandleEvent test
robotConfigOnDemandEvent := map[string]interface{}{
"identity": map[string]interface{}{
"role": "On-Demand Event Robot",
},
"triggers": map[string]interface{}{
"clock": map[string]interface{}{"enabled": false},
"event": map[string]interface{}{"enabled": true},
},
"quota": map[string]interface{}{
"max": 2,
},
}
configOnDemandEventJSON, _ := json.Marshal(robotConfigOnDemandEvent)
err = qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_manager_on_demand_event",
"team_id": "team_test_manager",
"member_type": "robot",
"display_name": "Test On-Demand Event Robot",
"status": "active",
"role_id": "member",
"autonomous_mode": false, // Non-autonomous!
"robot_status": "idle",
"robot_config": string(configOnDemandEventJSON),
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_manager_on_demand_event: %v", err)
}
}
// cleanupTestRobots removes all test robot records
func cleanupTestRobots(t *testing.T) {
qb := capsule.Query()
@ -1417,6 +1683,10 @@ func cleanupTestRobots(t *testing.T) {
"robot_test_manager_intervene_disabled",
"robot_test_manager_event",
"robot_test_manager_event_disabled",
// Non-autonomous robots
"robot_test_manager_on_demand",
"robot_test_manager_on_demand_intervene",
"robot_test_manager_on_demand_event",
}
for _, id := range testRobotIDs {

View file

@ -13,12 +13,11 @@ import (
// ExecutionRecord - persistent storage for robot execution history
// Maps to __yao.agent_execution model
type ExecutionRecord struct {
ID int64 `json:"id,omitempty"` // Auto-increment primary key
ExecutionID string `json:"execution_id"` // Unique execution identifier
MemberID string `json:"member_id"` // Robot member ID (globally unique)
TeamID string `json:"team_id"` // Team ID
JobID string `json:"job_id,omitempty"` // Linked job.Job ID
TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event
ID int64 `json:"id,omitempty"` // Auto-increment primary key
ExecutionID string `json:"execution_id"` // Unique execution identifier
MemberID string `json:"member_id"` // Robot member ID (globally unique)
TeamID string `json:"team_id"` // Team ID
TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event
// Status tracking (synced with runtime Execution)
Status types.ExecStatus `json:"status"` // pending | running | completed | failed | cancelled
@ -344,9 +343,6 @@ func (s *ExecutionStore) recordToMap(record *ExecutionRecord) map[string]interfa
"phase": string(record.Phase),
}
if record.JobID != "" {
data["job_id"] = record.JobID
}
if record.Error != "" {
data["error"] = record.Error
}
@ -408,9 +404,6 @@ func (s *ExecutionStore) mapToRecord(row map[string]interface{}) (*ExecutionReco
if v, ok := row["team_id"].(string); ok {
record.TeamID = v
}
if v, ok := row["job_id"].(string); ok {
record.JobID = v
}
if v, ok := row["trigger_type"].(string); ok {
record.TriggerType = types.TriggerType(v)
}
@ -634,7 +627,6 @@ func FromExecution(exec *types.Execution) *ExecutionRecord {
ExecutionID: exec.ID,
MemberID: exec.MemberID,
TeamID: exec.TeamID,
JobID: exec.JobID,
TriggerType: exec.TriggerType,
Status: exec.Status,
Phase: exec.Phase,
@ -673,7 +665,6 @@ func (r *ExecutionRecord) ToExecution() *types.Execution {
ID: r.ExecutionID,
MemberID: r.MemberID,
TeamID: r.TeamID,
JobID: r.JobID,
TriggerType: r.TriggerType,
Status: r.Status,
Phase: r.Phase,

View file

@ -35,7 +35,6 @@ func TestExecutionStoreSave(t *testing.T) {
ExecutionID: "exec_test_save_001",
MemberID: "member_test_001",
TeamID: "team_test_001",
JobID: "job_test_001",
TriggerType: types.TriggerClock,
Status: types.ExecPending,
Phase: types.PhaseInspiration,
@ -53,7 +52,6 @@ func TestExecutionStoreSave(t *testing.T) {
assert.Equal(t, "exec_test_save_001", saved.ExecutionID)
assert.Equal(t, "member_test_001", saved.MemberID)
assert.Equal(t, "team_test_001", saved.TeamID)
assert.Equal(t, "job_test_001", saved.JobID)
assert.Equal(t, types.TriggerClock, saved.TriggerType)
assert.Equal(t, types.ExecPending, saved.Status)
assert.Equal(t, types.PhaseInspiration, saved.Phase)
@ -574,7 +572,6 @@ func TestExecutionRecordConversion(t *testing.T) {
ID: "exec_convert_001",
MemberID: "member_convert_001",
TeamID: "team_convert_001",
JobID: "job_convert_001",
TriggerType: types.TriggerHuman,
Status: types.ExecCompleted,
Phase: types.PhaseDelivery,
@ -600,7 +597,6 @@ func TestExecutionRecordConversion(t *testing.T) {
assert.Equal(t, "exec_convert_001", record.ExecutionID)
assert.Equal(t, "member_convert_001", record.MemberID)
assert.Equal(t, "team_convert_001", record.TeamID)
assert.Equal(t, "job_convert_001", record.JobID)
assert.Equal(t, types.TriggerHuman, record.TriggerType)
assert.Equal(t, types.ExecCompleted, record.Status)
assert.Equal(t, types.PhaseDelivery, record.Phase)
@ -621,7 +617,6 @@ func TestExecutionRecordConversion(t *testing.T) {
ExecutionID: "exec_convert_002",
MemberID: "member_convert_002",
TeamID: "team_convert_002",
JobID: "job_convert_002",
TriggerType: types.TriggerClock,
Status: types.ExecRunning,
Phase: types.PhaseRun,
@ -646,7 +641,6 @@ func TestExecutionRecordConversion(t *testing.T) {
assert.Equal(t, "exec_convert_002", exec.ID)
assert.Equal(t, "member_convert_002", exec.MemberID)
assert.Equal(t, "team_convert_002", exec.TeamID)
assert.Equal(t, "job_convert_002", exec.JobID)
assert.Equal(t, types.TriggerClock, exec.TriggerType)
assert.Equal(t, types.ExecRunning, exec.Status)
assert.Equal(t, types.PhaseRun, exec.Phase)
@ -686,7 +680,6 @@ func setupTestExecution(t *testing.T, s *store.ExecutionStore, ctx context.Conte
ExecutionID: "exec_test_get_001",
MemberID: "member_test_get",
TeamID: "team_test_get",
JobID: "job_test_get",
TriggerType: types.TriggerClock,
Status: types.ExecCompleted,
Phase: types.PhaseDelivery,

640
agent/robot/store/robot.go Normal file
View file

@ -0,0 +1,640 @@
package store
import (
"context"
"fmt"
"time"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/robot/utils"
)
// RobotRecord - persistent storage for robot member
// Maps to __yao.member model
type RobotRecord struct {
ID int64 `json:"id,omitempty"` // Auto-increment primary key
MemberID string `json:"member_id"` // Unique robot identifier
TeamID string `json:"team_id"` // Team ID
MemberType string `json:"member_type"` // Always "robot" for robots
Status string `json:"status"` // Member status: active | inactive | pending | suspended
RobotStatus string `json:"robot_status"` // Robot status: idle | working | paused | error | maintenance
AutonomousMode bool `json:"autonomous_mode"` // Whether autonomous mode is enabled
// Profile
DisplayName string `json:"display_name"` // Display name
Bio string `json:"bio,omitempty"` // Robot description
Avatar string `json:"avatar,omitempty"`
// Identity & Role
SystemPrompt string `json:"system_prompt"` // System prompt
RoleID string `json:"role_id"` // Role within team
ManagerID string `json:"manager_id"` // Direct manager user_id (who manages this robot)
// Communication
RobotEmail string `json:"robot_email"` // Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
// Capabilities
RobotConfig interface{} `json:"robot_config"` // Robot config JSON
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
LanguageModel string `json:"language_model,omitempty"` // Language model name
// Limits
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
// Ownership & Audit
InvitedBy string `json:"invited_by,omitempty"` // Who created/added this robot
JoinedAt *time.Time `json:"joined_at,omitempty"` // When robot was created
// Timestamps
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
// Yao Permission Fields (automatically handled by Yao model when permission:true)
// These fields are passed through to the model layer for permission control
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id (set on create)
YaoUpdatedBy string `json:"__yao_updated_by,omitempty"` // Updater user_id (set on update)
YaoTeamID string `json:"__yao_team_id,omitempty"` // Permission team scope
YaoTenantID string `json:"__yao_tenant_id,omitempty"` // Permission tenant scope
}
// RobotListOptions - options for listing robot records
type RobotListOptions struct {
TeamID string `json:"team_id,omitempty"`
Status types.RobotStatus `json:"status,omitempty"`
Keywords string `json:"keywords,omitempty"` // Search in display_name
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
Page int `json:"page,omitempty"`
PageSize int `json:"pagesize,omitempty"`
OrderBy string `json:"order_by,omitempty"`
}
// RobotStore - persistent storage for robot members
type RobotStore struct {
modelID string
}
// NewRobotStore creates a new robot store instance
func NewRobotStore() *RobotStore {
return &RobotStore{
modelID: "__yao.member",
}
}
// robotFields are the fields to select when loading robots
var robotFields = []interface{}{
// Basic
"id",
"member_id",
"team_id",
"member_type",
"status",
"robot_status",
"autonomous_mode",
// Profile
"display_name",
"bio",
"avatar",
// Identity & Role
"system_prompt",
"role_id",
"manager_id",
// Communication
"robot_email",
"authorized_senders",
"email_filter_rules",
// Capabilities
"robot_config",
"agents",
"mcp_servers",
"language_model",
// Limits
"cost_limit",
// Ownership & Audit
"invited_by",
"joined_at",
// Timestamps
"created_at",
"updated_at",
// Yao Permission Fields (for access control)
"__yao_created_by",
"__yao_updated_by",
"__yao_team_id",
"__yao_tenant_id",
}
// Save creates or updates a robot member record
func (s *RobotStore) Save(ctx context.Context, record *RobotRecord) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
// Ensure member_type is robot
record.MemberType = "robot"
data := s.recordToMap(record)
// Check if record exists by member_id
existing, err := s.Get(ctx, record.MemberID)
if err == nil && existing != nil {
// Update existing record
_, err = mod.UpdateWhere(
model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: record.MemberID},
},
},
data,
)
if err != nil {
return fmt.Errorf("failed to update robot record: %w", err)
}
return nil
}
// Create new record
_, err = mod.Create(data)
if err != nil {
return fmt.Errorf("failed to create robot record: %w", err)
}
return nil
}
// Get retrieves a robot record by member_id
func (s *RobotStore) Get(ctx context.Context, memberID string) (*RobotRecord, error) {
mod := model.Select(s.modelID)
if mod == nil {
return nil, fmt.Errorf("model %s not found", s.modelID)
}
rows, err := mod.Get(model.QueryParam{
Select: robotFields,
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf("failed to get robot record: %w", err)
}
if len(rows) == 0 {
return nil, nil
}
return s.mapToRecord(rows[0])
}
// List retrieves robot records with filters
func (s *RobotStore) List(ctx context.Context, opts *RobotListOptions) ([]*RobotRecord, int, error) {
mod := model.Select(s.modelID)
if mod == nil {
return nil, 0, fmt.Errorf("model %s not found", s.modelID)
}
// Build where conditions - only require member_type=robot
wheres := []model.QueryWhere{
{Column: "member_type", Value: "robot"},
}
if opts != nil {
if opts.TeamID != "" {
wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID})
}
if opts.Status != "" {
wheres = append(wheres, model.QueryWhere{Column: "robot_status", Value: string(opts.Status)})
}
if opts.Keywords != "" {
wheres = append(wheres, model.QueryWhere{
Column: "display_name",
OP: "like",
Value: "%" + opts.Keywords + "%",
})
}
}
// Build order
orders := []model.QueryOrder{}
if opts != nil && opts.OrderBy != "" {
orders = append(orders, model.QueryOrder{Column: opts.OrderBy})
} else {
orders = append(orders, model.QueryOrder{Column: "created_at", Option: "desc"})
}
// Determine pagination
page := 1
pageSize := 100
if opts != nil {
if opts.Page > 0 {
page = opts.Page
}
if opts.PageSize > 0 {
pageSize = opts.PageSize
}
// Limit overrides PageSize for simple limit queries
if opts.Limit > 0 {
pageSize = opts.Limit
}
}
// Execute paginated query
result, err := mod.Paginate(model.QueryParam{
Select: robotFields,
Wheres: wheres,
Orders: orders,
}, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("failed to list robots: %w", err)
}
// Get total count
total := 0
if t, ok := result.Get("total").(int); ok {
total = t
}
// Parse records
records := []*RobotRecord{}
data := result.Get("data")
switch rows := data.(type) {
case []maps.MapStr:
for _, row := range rows {
record, err := s.mapToRecord(map[string]interface{}(row))
if err != nil {
continue // skip invalid records
}
records = append(records, record)
}
case []map[string]interface{}:
for _, row := range rows {
record, err := s.mapToRecord(row)
if err != nil {
continue // skip invalid records
}
records = append(records, record)
}
}
return records, total, nil
}
// Delete removes a robot member by member_id
func (s *RobotStore) Delete(ctx context.Context, memberID string) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
_, err := mod.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
})
if err != nil {
return fmt.Errorf("failed to delete robot record: %w", err)
}
return nil
}
// UpdateConfig updates only the robot_config field
func (s *RobotStore) UpdateConfig(ctx context.Context, memberID string, config interface{}) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
data := map[string]interface{}{
"robot_config": config,
}
_, err := mod.UpdateWhere(
model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
},
data,
)
if err != nil {
return fmt.Errorf("failed to update robot config: %w", err)
}
return nil
}
// UpdateStatus updates the robot_status field
func (s *RobotStore) UpdateStatus(ctx context.Context, memberID string, status types.RobotStatus) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
data := map[string]interface{}{
"robot_status": string(status),
}
_, err := mod.UpdateWhere(
model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
},
data,
)
if err != nil {
return fmt.Errorf("failed to update robot status: %w", err)
}
return nil
}
// recordToMap converts RobotRecord to map for model operations
func (s *RobotStore) recordToMap(record *RobotRecord) map[string]interface{} {
data := map[string]interface{}{
// Required fields
"member_id": record.MemberID,
"team_id": record.TeamID,
"member_type": "robot",
"autonomous_mode": record.AutonomousMode,
}
// Status
if record.Status != "" {
data["status"] = record.Status
} else {
data["status"] = "active"
}
if record.RobotStatus != "" {
data["robot_status"] = record.RobotStatus
} else {
data["robot_status"] = "idle"
}
// Profile
if record.DisplayName != "" {
data["display_name"] = record.DisplayName
}
if record.Bio != "" {
data["bio"] = record.Bio
}
if record.Avatar != "" {
data["avatar"] = record.Avatar
}
// Identity & Role
if record.SystemPrompt != "" {
data["system_prompt"] = record.SystemPrompt
}
if record.RoleID != "" {
data["role_id"] = record.RoleID
}
if record.ManagerID != "" {
data["manager_id"] = record.ManagerID
}
// Communication
if record.RobotEmail != "" {
data["robot_email"] = record.RobotEmail
}
if record.AuthorizedSenders != nil {
data["authorized_senders"] = record.AuthorizedSenders
}
if record.EmailFilterRules != nil {
data["email_filter_rules"] = record.EmailFilterRules
}
// Capabilities
if record.RobotConfig != nil {
data["robot_config"] = record.RobotConfig
}
if record.Agents != nil {
data["agents"] = record.Agents
}
if record.MCPServers != nil {
data["mcp_servers"] = record.MCPServers
}
if record.LanguageModel != "" {
data["language_model"] = record.LanguageModel
}
// Limits
if record.CostLimit > 0 {
data["cost_limit"] = record.CostLimit
}
// Ownership & Audit
if record.InvitedBy != "" {
data["invited_by"] = record.InvitedBy
}
if record.JoinedAt != nil {
// Format time for Gou model (expects string format)
data["joined_at"] = record.JoinedAt.Format("2006-01-02 15:04:05")
}
// Yao Permission Fields - pass through for model layer
if record.YaoCreatedBy != "" {
data["__yao_created_by"] = record.YaoCreatedBy
}
if record.YaoUpdatedBy != "" {
data["__yao_updated_by"] = record.YaoUpdatedBy
}
if record.YaoTeamID != "" {
data["__yao_team_id"] = record.YaoTeamID
}
if record.YaoTenantID != "" {
data["__yao_tenant_id"] = record.YaoTenantID
}
return data
}
// mapToRecord converts a model row to RobotRecord
func (s *RobotStore) mapToRecord(row map[string]interface{}) (*RobotRecord, error) {
record := &RobotRecord{}
// Basic fields
if v, ok := row["id"]; ok {
switch id := v.(type) {
case float64:
record.ID = int64(id)
case int64:
record.ID = id
case int:
record.ID = int64(id)
}
}
if v, ok := row["member_id"].(string); ok {
record.MemberID = v
}
if v, ok := row["team_id"].(string); ok {
record.TeamID = v
}
if v, ok := row["member_type"].(string); ok {
record.MemberType = v
}
if v, ok := row["status"].(string); ok {
record.Status = v
}
if v, ok := row["robot_status"].(string); ok {
record.RobotStatus = v
}
if v, ok := row["autonomous_mode"]; ok {
record.AutonomousMode = utils.ToBool(v)
}
// Profile
if v, ok := row["display_name"].(string); ok {
record.DisplayName = v
}
if v, ok := row["bio"].(string); ok {
record.Bio = v
}
if v, ok := row["avatar"].(string); ok {
record.Avatar = v
}
// Identity & Role
if v, ok := row["system_prompt"].(string); ok {
record.SystemPrompt = v
}
if v, ok := row["role_id"].(string); ok {
record.RoleID = v
}
if v, ok := row["manager_id"].(string); ok {
record.ManagerID = v
}
// Communication
if v, ok := row["robot_email"].(string); ok {
record.RobotEmail = v
}
if v := row["authorized_senders"]; v != nil {
record.AuthorizedSenders = utils.ToJSONValue(v)
}
if v := row["email_filter_rules"]; v != nil {
record.EmailFilterRules = utils.ToJSONValue(v)
}
// Capabilities
if v := row["robot_config"]; v != nil {
record.RobotConfig = utils.ToJSONValue(v)
}
if v := row["agents"]; v != nil {
record.Agents = utils.ToJSONValue(v)
}
if v := row["mcp_servers"]; v != nil {
record.MCPServers = utils.ToJSONValue(v)
}
if v, ok := row["language_model"].(string); ok {
record.LanguageModel = v
}
// Limits
if v := row["cost_limit"]; v != nil {
record.CostLimit = utils.ToFloat64(v)
}
// Ownership & Audit
if v, ok := row["invited_by"].(string); ok {
record.InvitedBy = v
}
if v := row["joined_at"]; v != nil {
record.JoinedAt = utils.ToTimestamp(v)
}
// Timestamps
if v := row["created_at"]; v != nil {
record.CreatedAt = utils.ToTimestamp(v)
}
if v := row["updated_at"]; v != nil {
record.UpdatedAt = utils.ToTimestamp(v)
}
// Yao Permission Fields
if v, ok := row["__yao_created_by"].(string); ok {
record.YaoCreatedBy = v
}
if v, ok := row["__yao_updated_by"].(string); ok {
record.YaoUpdatedBy = v
}
if v, ok := row["__yao_team_id"].(string); ok {
record.YaoTeamID = v
}
if v, ok := row["__yao_tenant_id"].(string); ok {
record.YaoTenantID = v
}
return record, nil
}
// ToRobot converts a RobotRecord to types.Robot
func (r *RobotRecord) ToRobot() (*types.Robot, error) {
robot := &types.Robot{
MemberID: r.MemberID,
TeamID: r.TeamID,
DisplayName: r.DisplayName,
Bio: r.Bio,
SystemPrompt: r.SystemPrompt,
AutonomousMode: r.AutonomousMode,
RobotEmail: r.RobotEmail,
}
// Parse robot_status
if r.RobotStatus != "" {
robot.Status = types.RobotStatus(r.RobotStatus)
} else {
robot.Status = types.RobotIdle
}
// Parse robot_config
if r.RobotConfig != nil {
config, err := types.ParseConfig(r.RobotConfig)
if err != nil {
return nil, fmt.Errorf("failed to parse robot_config: %w", err)
}
robot.Config = config
}
return robot, nil
}
// FromRobot creates a RobotRecord from types.Robot
func FromRobot(robot *types.Robot) *RobotRecord {
record := &RobotRecord{
MemberID: robot.MemberID,
TeamID: robot.TeamID,
DisplayName: robot.DisplayName,
Bio: robot.Bio,
SystemPrompt: robot.SystemPrompt,
RobotStatus: string(robot.Status),
AutonomousMode: robot.AutonomousMode,
RobotEmail: robot.RobotEmail,
MemberType: "robot",
Status: "active",
}
if robot.Config != nil {
record.RobotConfig = robot.Config
}
return record
}

View file

@ -0,0 +1,578 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/yao/agent/robot/store"
"github.com/yaoapp/yao/agent/testutils"
)
// TestRobotStoreSave tests creating and updating robot records
func TestRobotStoreSave(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
defer cleanupTestRobots(t)
s := store.NewRobotStore()
ctx := context.Background()
t.Run("creates_new_robot_record", func(t *testing.T) {
now := time.Now()
record := &store.RobotRecord{
MemberID: "robot_test_save_001",
TeamID: "team_test_001",
DisplayName: "Test Robot 001",
Bio: "A test robot for save operations",
SystemPrompt: "You are a helpful assistant",
Status: "active",
RobotStatus: "idle",
AutonomousMode: true,
RobotEmail: "robot001@test.com",
JoinedAt: &now,
}
err := s.Save(ctx, record)
require.NoError(t, err)
// Verify it was created
saved, err := s.Get(ctx, "robot_test_save_001")
require.NoError(t, err)
require.NotNil(t, saved)
assert.Equal(t, "robot_test_save_001", saved.MemberID)
assert.Equal(t, "team_test_001", saved.TeamID)
assert.Equal(t, "Test Robot 001", saved.DisplayName)
assert.Equal(t, "A test robot for save operations", saved.Bio)
assert.Equal(t, "You are a helpful assistant", saved.SystemPrompt)
assert.Equal(t, "active", saved.Status)
assert.Equal(t, "idle", saved.RobotStatus)
assert.True(t, saved.AutonomousMode)
assert.Equal(t, "robot001@test.com", saved.RobotEmail)
assert.Equal(t, "robot", saved.MemberType)
assert.NotNil(t, saved.JoinedAt)
})
t.Run("updates_existing_robot_record", func(t *testing.T) {
// First create a record
record := &store.RobotRecord{
MemberID: "robot_test_save_002",
TeamID: "team_test_002",
DisplayName: "Original Name",
Status: "active",
RobotStatus: "idle",
}
err := s.Save(ctx, record)
require.NoError(t, err)
// Update the record
record.DisplayName = "Updated Name"
record.Bio = "Updated bio"
record.RobotStatus = "working"
err = s.Save(ctx, record)
require.NoError(t, err)
// Verify the update
saved, err := s.Get(ctx, "robot_test_save_002")
require.NoError(t, err)
require.NotNil(t, saved)
assert.Equal(t, "Updated Name", saved.DisplayName)
assert.Equal(t, "Updated bio", saved.Bio)
assert.Equal(t, "working", saved.RobotStatus)
})
t.Run("saves_robot_with_config", func(t *testing.T) {
record := &store.RobotRecord{
MemberID: "robot_test_save_003",
TeamID: "team_test_003",
DisplayName: "Robot with Config",
Status: "active",
RobotStatus: "idle",
RobotConfig: map[string]interface{}{
"clock_mode": "on",
"max_concurrent": 3,
"timeout_seconds": 300,
},
}
err := s.Save(ctx, record)
require.NoError(t, err)
saved, err := s.Get(ctx, "robot_test_save_003")
require.NoError(t, err)
require.NotNil(t, saved)
assert.NotNil(t, saved.RobotConfig)
})
t.Run("saves_robot_with_permission_fields", func(t *testing.T) {
record := &store.RobotRecord{
MemberID: "robot_test_save_004",
TeamID: "team_test_004",
DisplayName: "Robot with Perms",
Status: "active",
RobotStatus: "idle",
YaoCreatedBy: "user_001",
YaoTeamID: "team_001",
YaoTenantID: "tenant_001",
}
err := s.Save(ctx, record)
require.NoError(t, err)
// Yao permission fields are handled by the model layer
saved, err := s.Get(ctx, "robot_test_save_004")
require.NoError(t, err)
require.NotNil(t, saved)
})
}
// TestRobotStoreGet tests retrieving robot records
func TestRobotStoreGet(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
defer cleanupTestRobots(t)
s := store.NewRobotStore()
ctx := context.Background()
// Create a test record
setupTestRobot(t, s, ctx)
t.Run("returns_existing_record", func(t *testing.T) {
record, err := s.Get(ctx, "robot_test_get_001")
require.NoError(t, err)
require.NotNil(t, record)
assert.Equal(t, "robot_test_get_001", record.MemberID)
assert.Equal(t, "team_test_get", record.TeamID)
assert.Equal(t, "Test Robot Get", record.DisplayName)
assert.Equal(t, "Test robot description", record.Bio)
assert.Equal(t, "robot", record.MemberType)
assert.Equal(t, "active", record.Status)
assert.Equal(t, "idle", record.RobotStatus)
})
t.Run("returns_nil_for_non_existent_record", func(t *testing.T) {
record, err := s.Get(ctx, "robot_non_existent")
require.NoError(t, err)
assert.Nil(t, record)
})
t.Run("ignores_non_robot_members", func(t *testing.T) {
// Get should only return member_type="robot" records
record, err := s.Get(ctx, "robot_test_get_001")
require.NoError(t, err)
require.NotNil(t, record)
assert.Equal(t, "robot", record.MemberType)
})
}
// TestRobotStoreList tests listing robot records with filters
func TestRobotStoreList(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
defer cleanupTestRobots(t)
s := store.NewRobotStore()
ctx := context.Background()
// Create multiple test records
setupTestRobotsForList(t, s, ctx)
t.Run("lists_all_robot_records", func(t *testing.T) {
// List with keywords filter to only get our test records
// Test robots have display names like "Robot Alpha", "Robot Beta", etc.
records, total, err := s.List(ctx, &store.RobotListOptions{
Keywords: "Robot",
})
require.NoError(t, err)
// Should find at least our 4 test robots
assert.GreaterOrEqual(t, len(records), 4)
assert.GreaterOrEqual(t, total, 4)
})
t.Run("filters_by_team_id", func(t *testing.T) {
records, total, err := s.List(ctx, &store.RobotListOptions{
TeamID: "team_list_001",
})
require.NoError(t, err)
assert.Equal(t, 2, len(records))
assert.Equal(t, 2, total)
for _, r := range records {
assert.Equal(t, "team_list_001", r.TeamID)
}
})
t.Run("filters_by_robot_status", func(t *testing.T) {
records, _, err := s.List(ctx, &store.RobotListOptions{
Status: "working",
})
require.NoError(t, err)
assert.GreaterOrEqual(t, len(records), 1)
for _, r := range records {
assert.Equal(t, "working", r.RobotStatus)
}
})
t.Run("filters_by_keywords", func(t *testing.T) {
records, _, err := s.List(ctx, &store.RobotListOptions{
Keywords: "Alpha",
})
require.NoError(t, err)
assert.Equal(t, 1, len(records))
assert.Contains(t, records[0].DisplayName, "Alpha")
})
t.Run("respects_pagination", func(t *testing.T) {
records, total, err := s.List(ctx, &store.RobotListOptions{
Page: 1,
PageSize: 2,
})
require.NoError(t, err)
assert.Equal(t, 2, len(records))
assert.GreaterOrEqual(t, total, 4) // total count should be full count
})
t.Run("respects_limit", func(t *testing.T) {
records, _, err := s.List(ctx, &store.RobotListOptions{
Limit: 2,
})
require.NoError(t, err)
assert.Equal(t, 2, len(records))
})
t.Run("combines_multiple_filters", func(t *testing.T) {
records, total, err := s.List(ctx, &store.RobotListOptions{
TeamID: "team_list_001",
Status: "idle",
})
require.NoError(t, err)
assert.Equal(t, 1, len(records))
assert.Equal(t, 1, total)
assert.Equal(t, "team_list_001", records[0].TeamID)
assert.Equal(t, "idle", records[0].RobotStatus)
})
}
// TestRobotStoreDelete tests deleting robot records
func TestRobotStoreDelete(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
defer cleanupTestRobots(t)
s := store.NewRobotStore()
ctx := context.Background()
t.Run("deletes_existing_record", func(t *testing.T) {
// Create a record
record := &store.RobotRecord{
MemberID: "robot_test_delete_001",
TeamID: "team_delete_001",
DisplayName: "Robot to Delete",
Status: "active",
RobotStatus: "idle",
}
err := s.Save(ctx, record)
require.NoError(t, err)
// Verify it exists
saved, err := s.Get(ctx, "robot_test_delete_001")
require.NoError(t, err)
require.NotNil(t, saved)
// Delete it
err = s.Delete(ctx, "robot_test_delete_001")
require.NoError(t, err)
// Verify it's gone
saved, err = s.Get(ctx, "robot_test_delete_001")
require.NoError(t, err)
assert.Nil(t, saved)
})
t.Run("no_error_for_non_existent_record", func(t *testing.T) {
err := s.Delete(ctx, "robot_non_existent")
assert.NoError(t, err)
})
}
// TestRobotStoreUpdateConfig tests updating robot config
func TestRobotStoreUpdateConfig(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
defer cleanupTestRobots(t)
s := store.NewRobotStore()
ctx := context.Background()
// Create a base record
record := &store.RobotRecord{
MemberID: "robot_test_config_001",
TeamID: "team_config_001",
DisplayName: "Config Test Robot",
Status: "active",
RobotStatus: "idle",
RobotConfig: map[string]interface{}{
"clock_mode": "off",
},
}
err := s.Save(ctx, record)
require.NoError(t, err)
t.Run("updates_config_only", func(t *testing.T) {
newConfig := map[string]interface{}{
"clock_mode": "on",
"max_concurrent": 5,
"timeout_seconds": 600,
}
err := s.UpdateConfig(ctx, "robot_test_config_001", newConfig)
require.NoError(t, err)
saved, err := s.Get(ctx, "robot_test_config_001")
require.NoError(t, err)
require.NotNil(t, saved)
assert.NotNil(t, saved.RobotConfig)
// Display name should be unchanged
assert.Equal(t, "Config Test Robot", saved.DisplayName)
})
}
// TestRobotStoreUpdateStatus tests updating robot status
func TestRobotStoreUpdateStatus(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
defer cleanupTestRobots(t)
s := store.NewRobotStore()
ctx := context.Background()
// Create a base record
record := &store.RobotRecord{
MemberID: "robot_test_status_001",
TeamID: "team_status_001",
DisplayName: "Status Test Robot",
Status: "active",
RobotStatus: "idle",
}
err := s.Save(ctx, record)
require.NoError(t, err)
t.Run("updates_robot_status", func(t *testing.T) {
err := s.UpdateStatus(ctx, "robot_test_status_001", "working")
require.NoError(t, err)
saved, err := s.Get(ctx, "robot_test_status_001")
require.NoError(t, err)
require.NotNil(t, saved)
assert.Equal(t, "working", saved.RobotStatus)
// Display name should be unchanged
assert.Equal(t, "Status Test Robot", saved.DisplayName)
})
t.Run("updates_to_paused", func(t *testing.T) {
err := s.UpdateStatus(ctx, "robot_test_status_001", "paused")
require.NoError(t, err)
saved, err := s.Get(ctx, "robot_test_status_001")
require.NoError(t, err)
assert.Equal(t, "paused", saved.RobotStatus)
})
t.Run("updates_to_error", func(t *testing.T) {
err := s.UpdateStatus(ctx, "robot_test_status_001", "error")
require.NoError(t, err)
saved, err := s.Get(ctx, "robot_test_status_001")
require.NoError(t, err)
assert.Equal(t, "error", saved.RobotStatus)
})
}
// TestRobotRecordConversion tests conversion between RobotRecord and Robot types
func TestRobotRecordConversion(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("converts_record_to_robot", func(t *testing.T) {
now := time.Now()
record := &store.RobotRecord{
MemberID: "robot_convert_001",
TeamID: "team_convert_001",
DisplayName: "Conversion Test Robot",
Bio: "Test description",
SystemPrompt: "You are helpful",
Status: "active",
RobotStatus: "idle",
AutonomousMode: true,
RobotEmail: "convert@test.com",
JoinedAt: &now,
RobotConfig: map[string]interface{}{
"clock_mode": "on",
},
}
robot, err := record.ToRobot()
require.NoError(t, err)
require.NotNil(t, robot)
assert.Equal(t, "robot_convert_001", robot.MemberID)
assert.Equal(t, "team_convert_001", robot.TeamID)
assert.Equal(t, "Conversion Test Robot", robot.DisplayName)
assert.Equal(t, "Test description", robot.Bio)
assert.Equal(t, "You are helpful", robot.SystemPrompt)
assert.True(t, robot.AutonomousMode)
assert.Equal(t, "convert@test.com", robot.RobotEmail)
})
t.Run("converts_robot_to_record", func(t *testing.T) {
robot := &store.RobotRecord{
MemberID: "robot_from_001",
TeamID: "team_from_001",
DisplayName: "From Robot Test",
Bio: "From robot description",
SystemPrompt: "System prompt",
RobotStatus: "working",
AutonomousMode: false,
RobotEmail: "from@test.com",
}
// ToRobot and verify
converted, err := robot.ToRobot()
require.NoError(t, err)
assert.Equal(t, "robot_from_001", converted.MemberID)
assert.Equal(t, "team_from_001", converted.TeamID)
assert.Equal(t, "From Robot Test", converted.DisplayName)
})
}
// Helper functions
func cleanupTestRobots(t *testing.T) {
mod := model.Select("__yao.member")
if mod == nil {
return
}
// Delete all test robot records
_, err := mod.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", OP: "like", Value: "robot_test_%"},
{Column: "member_type", Value: "robot"},
},
})
if err != nil {
t.Logf("Warning: failed to cleanup test robots: %v", err)
}
}
func setupTestRobot(t *testing.T, s *store.RobotStore, ctx context.Context) {
now := time.Now()
record := &store.RobotRecord{
MemberID: "robot_test_get_001",
TeamID: "team_test_get",
DisplayName: "Test Robot Get",
Bio: "Test robot description",
SystemPrompt: "You are a test assistant",
Status: "active",
RobotStatus: "idle",
AutonomousMode: false,
RobotEmail: "test@robot.com",
JoinedAt: &now,
}
err := s.Save(ctx, record)
require.NoError(t, err)
}
func setupTestRobotsForList(t *testing.T, s *store.RobotStore, ctx context.Context) {
now := time.Now()
records := []*store.RobotRecord{
{
MemberID: "robot_test_list_001",
TeamID: "team_list_001",
DisplayName: "Robot Alpha",
Status: "active",
RobotStatus: "idle",
JoinedAt: &now,
},
{
MemberID: "robot_test_list_002",
TeamID: "team_list_001",
DisplayName: "Robot Beta",
Status: "active",
RobotStatus: "working",
JoinedAt: &now,
},
{
MemberID: "robot_test_list_003",
TeamID: "team_list_002",
DisplayName: "Robot Gamma",
Status: "active",
RobotStatus: "idle",
JoinedAt: &now,
},
{
MemberID: "robot_test_list_004",
TeamID: "team_list_002",
DisplayName: "Robot Delta",
Status: "inactive",
RobotStatus: "paused",
JoinedAt: &now,
},
}
for _, record := range records {
err := s.Save(ctx, record)
require.NoError(t, err)
}
}

View file

@ -11,12 +11,13 @@ import (
// Robot - runtime representation of an autonomous robot (from __yao.member)
// Relationship: 1 Robot : N Executions (concurrent)
// Each trigger creates a new Execution (mapped to job.Job)
// Each trigger creates a new Execution (stored in __yao.agent_execution)
type Robot struct {
// From __yao.member
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
DisplayName string `json:"display_name"`
Bio string `json:"bio"` // Robot's description (from __yao.member.bio)
SystemPrompt string `json:"system_prompt"`
Status RobotStatus `json:"robot_status"`
AutonomousMode bool `json:"autonomous_mode"`
@ -116,8 +117,7 @@ func (r *Robot) GetExecutions() []*Execution {
}
// Execution - single execution instance
// Each trigger creates a new Execution, mapped to a job.Job for monitoring
// Relationship: 1 Execution = 1 job.Job
// Each trigger creates a new Execution, stored in ExecutionStore
type Execution struct {
ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID
@ -129,9 +129,6 @@ type Execution struct {
Phase Phase `json:"phase"`
Error string `json:"error,omitempty"`
// Job integration (each Execution = 1 job.Job)
JobID string `json:"job_id"` // corresponding job.Job ID
// Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input
@ -381,6 +378,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
MemberID: memberID,
TeamID: teamID,
DisplayName: getString(m, "display_name"),
Bio: getString(m, "bio"),
SystemPrompt: getString(m, "system_prompt"),
AutonomousMode: getBool(m, "autonomous_mode"),
RobotEmail: getString(m, "robot_email"),

View file

@ -362,7 +362,6 @@ func TestExecutionStructure(t *testing.T) {
TriggerType: types.TriggerClock,
Status: types.ExecRunning,
Phase: types.PhaseGoals,
JobID: "job1",
}
assert.Equal(t, "exec1", exec.ID)
@ -371,7 +370,6 @@ func TestExecutionStructure(t *testing.T) {
assert.Equal(t, types.TriggerClock, exec.TriggerType)
assert.Equal(t, types.ExecRunning, exec.Status)
assert.Equal(t, types.PhaseGoals, exec.Phase)
assert.Equal(t, "job1", exec.JobID)
})
t.Run("execution with trigger input", func(t *testing.T) {

View file

@ -3,8 +3,384 @@ package utils
import (
"encoding/json"
"fmt"
"time"
)
// ==================== To<Type> Functions ====================
// Convert any value to specified type (safe, returns zero value on failure)
// ToString converts any value to string
func ToString(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case []byte:
return string(val)
case int:
return fmt.Sprintf("%d", val)
case int8:
return fmt.Sprintf("%d", val)
case int16:
return fmt.Sprintf("%d", val)
case int32:
return fmt.Sprintf("%d", val)
case int64:
return fmt.Sprintf("%d", val)
case uint:
return fmt.Sprintf("%d", val)
case uint8:
return fmt.Sprintf("%d", val)
case uint16:
return fmt.Sprintf("%d", val)
case uint32:
return fmt.Sprintf("%d", val)
case uint64:
return fmt.Sprintf("%d", val)
case float32:
return fmt.Sprintf("%g", val)
case float64:
return fmt.Sprintf("%g", val)
case bool:
if val {
return "true"
}
return "false"
default:
if str, err := json.Marshal(v); err == nil {
return string(str)
}
return fmt.Sprintf("%v", v)
}
}
// ToBool converts any value to bool
func ToBool(v interface{}) bool {
if v == nil {
return false
}
switch b := v.(type) {
case bool:
return b
case int:
return b != 0
case int8:
return b != 0
case int16:
return b != 0
case int32:
return b != 0
case int64:
return b != 0
case uint:
return b != 0
case uint8:
return b != 0
case uint16:
return b != 0
case uint32:
return b != 0
case uint64:
return b != 0
case float32:
return b != 0
case float64:
return b != 0
case string:
return b == "true" || b == "1" || b == "yes" || b == "on"
}
return false
}
// ToInt converts any value to int
func ToInt(v interface{}) int {
if v == nil {
return 0
}
switch n := v.(type) {
case int:
return n
case int8:
return int(n)
case int16:
return int(n)
case int32:
return int(n)
case int64:
return int(n)
case uint:
return int(n)
case uint8:
return int(n)
case uint16:
return int(n)
case uint32:
return int(n)
case uint64:
return int(n)
case float32:
return int(n)
case float64:
return int(n)
case string:
var i int
fmt.Sscanf(n, "%d", &i)
return i
case bool:
if n {
return 1
}
return 0
}
return 0
}
// ToInt64 converts any value to int64
func ToInt64(v interface{}) int64 {
if v == nil {
return 0
}
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case int8:
return int64(n)
case int16:
return int64(n)
case int32:
return int64(n)
case uint:
return int64(n)
case uint8:
return int64(n)
case uint16:
return int64(n)
case uint32:
return int64(n)
case uint64:
return int64(n)
case float32:
return int64(n)
case float64:
return int64(n)
case string:
var i int64
fmt.Sscanf(n, "%d", &i)
return i
case bool:
if n {
return 1
}
return 0
}
return 0
}
// ToFloat64 converts any value to float64
func ToFloat64(v interface{}) float64 {
if v == nil {
return 0
}
switch f := v.(type) {
case float64:
return f
case float32:
return float64(f)
case int:
return float64(f)
case int8:
return float64(f)
case int16:
return float64(f)
case int32:
return float64(f)
case int64:
return float64(f)
case uint:
return float64(f)
case uint8:
return float64(f)
case uint16:
return float64(f)
case uint32:
return float64(f)
case uint64:
return float64(f)
case string:
var result float64
fmt.Sscanf(f, "%f", &result)
return result
case bool:
if f {
return 1
}
return 0
}
return 0
}
// ToTimestamp converts any value to *time.Time
// Handles: time.Time, *time.Time, string (various formats), int64/float64 (unix timestamp)
func ToTimestamp(v interface{}) *time.Time {
if v == nil {
return nil
}
switch t := v.(type) {
case time.Time:
return &t
case *time.Time:
return t
case string:
if t == "" {
return nil
}
// Try common time formats
formats := []string{
time.RFC3339,
time.RFC3339Nano,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, format := range formats {
if parsed, err := time.Parse(format, t); err == nil {
return &parsed
}
}
case int64:
// Unix timestamp (seconds)
parsed := time.Unix(t, 0)
return &parsed
case int:
parsed := time.Unix(int64(t), 0)
return &parsed
case float64:
// Unix timestamp (seconds as float)
parsed := time.Unix(int64(t), 0)
return &parsed
}
return nil
}
// ToJSONValue parses JSON from string/[]byte or returns already-parsed value
func ToJSONValue(v interface{}) interface{} {
if v == nil {
return nil
}
switch data := v.(type) {
case string:
if data == "" {
return nil
}
var result interface{}
if err := json.Unmarshal([]byte(data), &result); err != nil {
return nil
}
return result
case []byte:
if len(data) == 0 {
return nil
}
var result interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil
}
return result
case map[string]interface{}, []interface{}:
// Already parsed
return data
default:
return v
}
}
// ==================== Get<Type> Functions ====================
// Safely get typed value from map[string]interface{}
// GetString safely gets a string value from map
func GetString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok {
return ToString(v)
}
return ""
}
// GetBool safely gets a bool value from map
func GetBool(m map[string]interface{}, key string) bool {
if m == nil {
return false
}
if v, ok := m[key]; ok {
return ToBool(v)
}
return false
}
// GetInt safely gets an int value from map
func GetInt(m map[string]interface{}, key string) int {
if m == nil {
return 0
}
if v, ok := m[key]; ok {
return ToInt(v)
}
return 0
}
// GetInt64 safely gets an int64 value from map
func GetInt64(m map[string]interface{}, key string) int64 {
if m == nil {
return 0
}
if v, ok := m[key]; ok {
return ToInt64(v)
}
return 0
}
// GetFloat64 safely gets a float64 value from map
func GetFloat64(m map[string]interface{}, key string) float64 {
if m == nil {
return 0
}
if v, ok := m[key]; ok {
return ToFloat64(v)
}
return 0
}
// GetTimestamp safely gets a *time.Time value from map
func GetTimestamp(m map[string]interface{}, key string) *time.Time {
if m == nil {
return nil
}
if v, ok := m[key]; ok {
return ToTimestamp(v)
}
return nil
}
// GetJSONValue safely gets a parsed JSON value from map
func GetJSONValue(m map[string]interface{}, key string) interface{} {
if m == nil {
return nil
}
if v, ok := m[key]; ok {
return ToJSONValue(v)
}
return nil
}
// ==================== JSON/Map Conversion ====================
// ToJSON converts any value to JSON string
func ToJSON(v interface{}) (string, error) {
data, err := json.Marshal(v)
@ -14,7 +390,7 @@ func ToJSON(v interface{}) (string, error) {
return string(data), nil
}
// FromJSON parses JSON string to target
// FromJSON parses JSON string to target struct
func FromJSON(jsonStr string, target interface{}) error {
return json.Unmarshal([]byte(jsonStr), target)
}
@ -25,12 +401,10 @@ func ToMap(v interface{}) (map[string]interface{}, error) {
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
@ -43,29 +417,7 @@ func FromMap(m map[string]interface{}, target interface{}) error {
return json.Unmarshal(data, target)
}
// ToString converts any value to string
func ToString(v interface{}) string {
switch val := v.(type) {
case string:
return val
case []byte:
return string(val)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", val)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", val)
case float32, float64:
return fmt.Sprintf("%f", val)
case bool:
return fmt.Sprintf("%t", val)
default:
// Fallback to JSON
if str, err := ToJSON(v); err == nil {
return str
}
return fmt.Sprintf("%v", v)
}
}
// ==================== Map Utilities ====================
// MergeMap merges source map into target map (shallow copy)
func MergeMap(target, source map[string]interface{}) map[string]interface{} {
@ -89,58 +441,3 @@ func CloneMap(m map[string]interface{}) map[string]interface{} {
}
return result
}
// GetString safely gets a string value from map
func GetString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok && v != nil {
return ToString(v)
}
return ""
}
// GetBool safely gets a bool value from map
func GetBool(m map[string]interface{}, key string) bool {
if m == nil {
return false
}
if v, ok := m[key]; ok && v != nil {
switch b := v.(type) {
case bool:
return b
case int:
return b != 0
case int64:
return b != 0
case float64:
return b != 0
case string:
return b == "true" || b == "1"
}
}
return false
}
// GetInt safely gets an int value from map
func GetInt(m map[string]interface{}, key string) int {
if m == nil {
return 0
}
if v, ok := m[key]; ok && v != nil {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case string:
var i int
fmt.Sscanf(n, "%d", &i)
return i
}
}
return 0
}

View file

@ -0,0 +1,567 @@
package utils_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/utils"
)
// ==================== To<Type> Tests ====================
func TestToBool(t *testing.T) {
t.Run("from_bool", func(t *testing.T) {
assert.True(t, utils.ToBool(true))
assert.False(t, utils.ToBool(false))
})
t.Run("from_int", func(t *testing.T) {
assert.True(t, utils.ToBool(1))
assert.True(t, utils.ToBool(42))
assert.False(t, utils.ToBool(0))
})
t.Run("from_int64", func(t *testing.T) {
assert.True(t, utils.ToBool(int64(1)))
assert.False(t, utils.ToBool(int64(0)))
})
t.Run("from_float64", func(t *testing.T) {
assert.True(t, utils.ToBool(1.0))
assert.True(t, utils.ToBool(0.1))
assert.False(t, utils.ToBool(0.0))
})
t.Run("from_string", func(t *testing.T) {
assert.True(t, utils.ToBool("true"))
assert.True(t, utils.ToBool("1"))
assert.True(t, utils.ToBool("yes"))
assert.True(t, utils.ToBool("on"))
assert.False(t, utils.ToBool("false"))
assert.False(t, utils.ToBool("0"))
assert.False(t, utils.ToBool(""))
})
t.Run("from_nil", func(t *testing.T) {
assert.False(t, utils.ToBool(nil))
})
t.Run("from_unsupported_type", func(t *testing.T) {
assert.False(t, utils.ToBool([]int{1, 2, 3}))
})
}
func TestToInt(t *testing.T) {
t.Run("from_int", func(t *testing.T) {
assert.Equal(t, 42, utils.ToInt(42))
assert.Equal(t, -10, utils.ToInt(-10))
})
t.Run("from_int64", func(t *testing.T) {
assert.Equal(t, 100, utils.ToInt(int64(100)))
})
t.Run("from_float64", func(t *testing.T) {
assert.Equal(t, 42, utils.ToInt(42.9)) // truncates
assert.Equal(t, -5, utils.ToInt(-5.7))
})
t.Run("from_string", func(t *testing.T) {
assert.Equal(t, 123, utils.ToInt("123"))
assert.Equal(t, -456, utils.ToInt("-456"))
assert.Equal(t, 0, utils.ToInt("invalid"))
})
t.Run("from_bool", func(t *testing.T) {
assert.Equal(t, 1, utils.ToInt(true))
assert.Equal(t, 0, utils.ToInt(false))
})
t.Run("from_nil", func(t *testing.T) {
assert.Equal(t, 0, utils.ToInt(nil))
})
}
func TestToInt64(t *testing.T) {
t.Run("from_int64", func(t *testing.T) {
assert.Equal(t, int64(9223372036854775807), utils.ToInt64(int64(9223372036854775807)))
})
t.Run("from_int", func(t *testing.T) {
assert.Equal(t, int64(42), utils.ToInt64(42))
})
t.Run("from_float64", func(t *testing.T) {
assert.Equal(t, int64(42), utils.ToInt64(42.9))
})
t.Run("from_string", func(t *testing.T) {
assert.Equal(t, int64(123456789), utils.ToInt64("123456789"))
})
t.Run("from_nil", func(t *testing.T) {
assert.Equal(t, int64(0), utils.ToInt64(nil))
})
}
func TestToFloat64(t *testing.T) {
t.Run("from_float64", func(t *testing.T) {
assert.Equal(t, 3.14159, utils.ToFloat64(3.14159))
})
t.Run("from_float32", func(t *testing.T) {
assert.InDelta(t, 3.14, utils.ToFloat64(float32(3.14)), 0.001)
})
t.Run("from_int", func(t *testing.T) {
assert.Equal(t, 42.0, utils.ToFloat64(42))
})
t.Run("from_int64", func(t *testing.T) {
assert.Equal(t, 100.0, utils.ToFloat64(int64(100)))
})
t.Run("from_string", func(t *testing.T) {
assert.InDelta(t, 3.14, utils.ToFloat64("3.14"), 0.001)
assert.Equal(t, 0.0, utils.ToFloat64("invalid"))
})
t.Run("from_bool", func(t *testing.T) {
assert.Equal(t, 1.0, utils.ToFloat64(true))
assert.Equal(t, 0.0, utils.ToFloat64(false))
})
t.Run("from_nil", func(t *testing.T) {
assert.Equal(t, 0.0, utils.ToFloat64(nil))
})
}
func TestToTimestamp(t *testing.T) {
t.Run("from_time_Time", func(t *testing.T) {
now := time.Now()
result := utils.ToTimestamp(now)
assert.NotNil(t, result)
assert.Equal(t, now.Unix(), result.Unix())
})
t.Run("from_time_Time_pointer", func(t *testing.T) {
now := time.Now()
result := utils.ToTimestamp(&now)
assert.NotNil(t, result)
assert.Equal(t, now.Unix(), result.Unix())
})
t.Run("from_RFC3339_string", func(t *testing.T) {
result := utils.ToTimestamp("2024-01-15T14:30:00Z")
assert.NotNil(t, result)
assert.Equal(t, 2024, result.Year())
assert.Equal(t, time.January, result.Month())
assert.Equal(t, 15, result.Day())
assert.Equal(t, 14, result.Hour())
assert.Equal(t, 30, result.Minute())
})
t.Run("from_datetime_string", func(t *testing.T) {
result := utils.ToTimestamp("2024-01-15 14:30:00")
assert.NotNil(t, result)
assert.Equal(t, 2024, result.Year())
})
t.Run("from_date_string", func(t *testing.T) {
result := utils.ToTimestamp("2024-01-15")
assert.NotNil(t, result)
assert.Equal(t, 2024, result.Year())
assert.Equal(t, 15, result.Day())
})
t.Run("from_unix_timestamp_int64", func(t *testing.T) {
// 2024-01-15 00:00:00 UTC
result := utils.ToTimestamp(int64(1705276800))
assert.NotNil(t, result)
assert.Equal(t, 2024, result.Year())
})
t.Run("from_unix_timestamp_float64", func(t *testing.T) {
result := utils.ToTimestamp(float64(1705276800))
assert.NotNil(t, result)
assert.Equal(t, 2024, result.Year())
})
t.Run("from_empty_string", func(t *testing.T) {
result := utils.ToTimestamp("")
assert.Nil(t, result)
})
t.Run("from_invalid_string", func(t *testing.T) {
result := utils.ToTimestamp("not a date")
assert.Nil(t, result)
})
t.Run("from_nil", func(t *testing.T) {
result := utils.ToTimestamp(nil)
assert.Nil(t, result)
})
}
func TestToJSONValue(t *testing.T) {
t.Run("from_json_string_object", func(t *testing.T) {
result := utils.ToJSONValue(`{"name":"test","age":30}`)
assert.NotNil(t, result)
m, ok := result.(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, "test", m["name"])
assert.Equal(t, float64(30), m["age"])
})
t.Run("from_json_string_array", func(t *testing.T) {
result := utils.ToJSONValue(`["a","b","c"]`)
assert.NotNil(t, result)
arr, ok := result.([]interface{})
assert.True(t, ok)
assert.Len(t, arr, 3)
assert.Equal(t, "a", arr[0])
})
t.Run("from_bytes", func(t *testing.T) {
result := utils.ToJSONValue([]byte(`{"key":"value"}`))
assert.NotNil(t, result)
m, ok := result.(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, "value", m["key"])
})
t.Run("from_already_parsed_map", func(t *testing.T) {
input := map[string]interface{}{"foo": "bar"}
result := utils.ToJSONValue(input)
assert.Equal(t, input, result)
})
t.Run("from_already_parsed_array", func(t *testing.T) {
input := []interface{}{"a", "b"}
result := utils.ToJSONValue(input)
assert.Equal(t, input, result)
})
t.Run("from_empty_string", func(t *testing.T) {
result := utils.ToJSONValue("")
assert.Nil(t, result)
})
t.Run("from_empty_bytes", func(t *testing.T) {
result := utils.ToJSONValue([]byte{})
assert.Nil(t, result)
})
t.Run("from_invalid_json", func(t *testing.T) {
result := utils.ToJSONValue("not json")
assert.Nil(t, result)
})
t.Run("from_nil", func(t *testing.T) {
result := utils.ToJSONValue(nil)
assert.Nil(t, result)
})
t.Run("from_other_type_passthrough", func(t *testing.T) {
// Non-string, non-[]byte types are passed through
result := utils.ToJSONValue(42)
assert.Equal(t, 42, result)
})
}
// ==================== Get<Type> Tests ====================
func TestGetString(t *testing.T) {
m := map[string]interface{}{
"name": "test",
"number": 42,
"bool": true,
"nil": nil,
}
t.Run("existing_string_key", func(t *testing.T) {
assert.Equal(t, "test", utils.GetString(m, "name"))
})
t.Run("converts_number_to_string", func(t *testing.T) {
assert.Equal(t, "42", utils.GetString(m, "number"))
})
t.Run("converts_bool_to_string", func(t *testing.T) {
assert.Equal(t, "true", utils.GetString(m, "bool"))
})
t.Run("non_existent_key", func(t *testing.T) {
assert.Equal(t, "", utils.GetString(m, "missing"))
})
t.Run("nil_map", func(t *testing.T) {
assert.Equal(t, "", utils.GetString(nil, "key"))
})
t.Run("nil_value", func(t *testing.T) {
assert.Equal(t, "", utils.GetString(m, "nil"))
})
}
func TestGetBool(t *testing.T) {
m := map[string]interface{}{
"bool_true": true,
"bool_false": false,
"int_one": 1,
"int_zero": 0,
"string_true": "true",
}
t.Run("bool_true", func(t *testing.T) {
assert.True(t, utils.GetBool(m, "bool_true"))
})
t.Run("bool_false", func(t *testing.T) {
assert.False(t, utils.GetBool(m, "bool_false"))
})
t.Run("int_one", func(t *testing.T) {
assert.True(t, utils.GetBool(m, "int_one"))
})
t.Run("int_zero", func(t *testing.T) {
assert.False(t, utils.GetBool(m, "int_zero"))
})
t.Run("string_true", func(t *testing.T) {
assert.True(t, utils.GetBool(m, "string_true"))
})
t.Run("non_existent_key", func(t *testing.T) {
assert.False(t, utils.GetBool(m, "missing"))
})
t.Run("nil_map", func(t *testing.T) {
assert.False(t, utils.GetBool(nil, "key"))
})
}
func TestGetInt(t *testing.T) {
m := map[string]interface{}{
"int": 42,
"int64": int64(100),
"float64": 3.14,
"string": "123",
}
t.Run("int", func(t *testing.T) {
assert.Equal(t, 42, utils.GetInt(m, "int"))
})
t.Run("int64", func(t *testing.T) {
assert.Equal(t, 100, utils.GetInt(m, "int64"))
})
t.Run("float64", func(t *testing.T) {
assert.Equal(t, 3, utils.GetInt(m, "float64"))
})
t.Run("string", func(t *testing.T) {
assert.Equal(t, 123, utils.GetInt(m, "string"))
})
t.Run("non_existent_key", func(t *testing.T) {
assert.Equal(t, 0, utils.GetInt(m, "missing"))
})
t.Run("nil_map", func(t *testing.T) {
assert.Equal(t, 0, utils.GetInt(nil, "key"))
})
}
func TestGetInt64(t *testing.T) {
m := map[string]interface{}{
"int64": int64(9223372036854775807),
"int": 42,
"string": "123456789",
}
t.Run("int64", func(t *testing.T) {
assert.Equal(t, int64(9223372036854775807), utils.GetInt64(m, "int64"))
})
t.Run("int", func(t *testing.T) {
assert.Equal(t, int64(42), utils.GetInt64(m, "int"))
})
t.Run("string", func(t *testing.T) {
assert.Equal(t, int64(123456789), utils.GetInt64(m, "string"))
})
t.Run("nil_map", func(t *testing.T) {
assert.Equal(t, int64(0), utils.GetInt64(nil, "key"))
})
}
func TestGetFloat64(t *testing.T) {
m := map[string]interface{}{
"float64": 3.14159,
"int": 42,
"string": "2.718",
}
t.Run("float64", func(t *testing.T) {
assert.Equal(t, 3.14159, utils.GetFloat64(m, "float64"))
})
t.Run("int", func(t *testing.T) {
assert.Equal(t, 42.0, utils.GetFloat64(m, "int"))
})
t.Run("string", func(t *testing.T) {
assert.InDelta(t, 2.718, utils.GetFloat64(m, "string"), 0.001)
})
t.Run("nil_map", func(t *testing.T) {
assert.Equal(t, 0.0, utils.GetFloat64(nil, "key"))
})
}
func TestGetTimestamp(t *testing.T) {
now := time.Now()
m := map[string]interface{}{
"time": now,
"time_ptr": &now,
"rfc3339": "2024-01-15T14:30:00Z",
"unix": int64(1705276800),
"empty": "",
"nil_value": nil,
}
t.Run("time_value", func(t *testing.T) {
result := utils.GetTimestamp(m, "time")
assert.NotNil(t, result)
assert.Equal(t, now.Unix(), result.Unix())
})
t.Run("time_ptr", func(t *testing.T) {
result := utils.GetTimestamp(m, "time_ptr")
assert.NotNil(t, result)
})
t.Run("rfc3339_string", func(t *testing.T) {
result := utils.GetTimestamp(m, "rfc3339")
assert.NotNil(t, result)
assert.Equal(t, 2024, result.Year())
})
t.Run("unix_timestamp", func(t *testing.T) {
result := utils.GetTimestamp(m, "unix")
assert.NotNil(t, result)
})
t.Run("empty_string", func(t *testing.T) {
result := utils.GetTimestamp(m, "empty")
assert.Nil(t, result)
})
t.Run("nil_value", func(t *testing.T) {
result := utils.GetTimestamp(m, "nil_value")
assert.Nil(t, result)
})
t.Run("non_existent_key", func(t *testing.T) {
result := utils.GetTimestamp(m, "missing")
assert.Nil(t, result)
})
t.Run("nil_map", func(t *testing.T) {
result := utils.GetTimestamp(nil, "key")
assert.Nil(t, result)
})
}
func TestGetJSONValue(t *testing.T) {
m := map[string]interface{}{
"json_string": `{"nested":"value"}`,
"json_array": `[1,2,3]`,
"parsed_map": map[string]interface{}{"foo": "bar"},
"empty": "",
"invalid": "not json",
}
t.Run("json_string", func(t *testing.T) {
result := utils.GetJSONValue(m, "json_string")
assert.NotNil(t, result)
nested, ok := result.(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, "value", nested["nested"])
})
t.Run("json_array", func(t *testing.T) {
result := utils.GetJSONValue(m, "json_array")
assert.NotNil(t, result)
arr, ok := result.([]interface{})
assert.True(t, ok)
assert.Len(t, arr, 3)
})
t.Run("parsed_map", func(t *testing.T) {
result := utils.GetJSONValue(m, "parsed_map")
assert.NotNil(t, result)
parsed, ok := result.(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, "bar", parsed["foo"])
})
t.Run("empty_string", func(t *testing.T) {
result := utils.GetJSONValue(m, "empty")
assert.Nil(t, result)
})
t.Run("invalid_json", func(t *testing.T) {
result := utils.GetJSONValue(m, "invalid")
assert.Nil(t, result)
})
t.Run("nil_map", func(t *testing.T) {
result := utils.GetJSONValue(nil, "key")
assert.Nil(t, result)
})
}
// ==================== ToString Extended Tests ====================
func TestToStringExtended(t *testing.T) {
t.Run("from_nil", func(t *testing.T) {
assert.Equal(t, "", utils.ToString(nil))
})
t.Run("from_bytes", func(t *testing.T) {
assert.Equal(t, "hello", utils.ToString([]byte("hello")))
})
t.Run("from_int_types", func(t *testing.T) {
assert.Equal(t, "8", utils.ToString(int8(8)))
assert.Equal(t, "16", utils.ToString(int16(16)))
assert.Equal(t, "32", utils.ToString(int32(32)))
assert.Equal(t, "64", utils.ToString(int64(64)))
})
t.Run("from_uint_types", func(t *testing.T) {
assert.Equal(t, "8", utils.ToString(uint8(8)))
assert.Equal(t, "16", utils.ToString(uint16(16)))
assert.Equal(t, "32", utils.ToString(uint32(32)))
assert.Equal(t, "64", utils.ToString(uint64(64)))
})
t.Run("from_float_formats_nicely", func(t *testing.T) {
assert.Equal(t, "3.14", utils.ToString(3.14))
assert.Equal(t, "1000", utils.ToString(1000.0)) // no trailing zeros
})
t.Run("from_struct_to_json", func(t *testing.T) {
type TestStruct struct {
Name string `json:"name"`
}
result := utils.ToString(TestStruct{Name: "test"})
assert.Contains(t, result, "test")
})
}

View file

@ -174,7 +174,8 @@ const (
// Used for filtering and pagination when retrieving assistant lists
type AssistantFilter struct {
Tags []string `json:"tags,omitempty"` // Filter by tags
Type string `json:"type,omitempty"` // Filter by type
Type string `json:"type,omitempty"` // Filter by type (single value)
Types []string `json:"types,omitempty"` // Filter by types (multiple values, IN query)
Keywords string `json:"keywords,omitempty"` // Search in name and description
Connector string `json:"connector,omitempty"` // Filter by connector
AssistantID string `json:"assistant_id,omitempty"` // Filter by assistant ID

View file

@ -353,11 +353,16 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
})
}
// Apply type filter if provided
// Apply type filter if provided (single value)
if filter.Type != "" {
qb.Where("type", filter.Type)
}
// Apply types filter if provided (multiple values, IN query)
if len(filter.Types) > 0 {
qb.WhereIn("type", filter.Types)
}
// Apply connector filter if provided
if filter.Connector != "" {
qb.Where("connector", filter.Connector)

View file

@ -40,13 +40,14 @@ type DSL struct {
// Uses the default assistant settings
// ===============================
type Uses struct {
Default string `json:"default,omitempty" yaml:"default,omitempty"` // The default assistant to use
Title string `json:"title,omitempty" yaml:"title,omitempty"` // The assistant for generating the topic title.
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // The assistant for generating the prompt.
Vision string `json:"vision,omitempty" yaml:"vision,omitempty"` // The assistant for generating the image/video description, if the assistant enable the vision and model not support vision, use the vision model to describe the image/video, and return the messages with the image/video's description. Format: "agent" or "mcp:mcp_server_id"
Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // The assistant for processing audio (speech-to-text, text-to-speech). If the model doesn't support audio, use this to convert audio to text. Format: "agent" or "mcp:mcp_server_id"
Search string `json:"search,omitempty" yaml:"search,omitempty"` // The assistant for searching the knowledge, global web search. If not set, and the assistant enable the knowledge, it will search the result from the knowledge automatically.
Fetch string `json:"fetch,omitempty" yaml:"fetch,omitempty"` // The assistant for fetching the http/https/ftp/sftp/etc. file, and return the file's content. if not set, use the http process to fetch the file.
Default string `json:"default,omitempty" yaml:"default,omitempty"` // The default assistant to use
Title string `json:"title,omitempty" yaml:"title,omitempty"` // The assistant for generating the topic title.
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // The assistant for generating the prompt.
RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // The assistant for generating Robot's system prompt (responsibilities description).
Vision string `json:"vision,omitempty" yaml:"vision,omitempty"` // The assistant for generating the image/video description, if the assistant enable the vision and model not support vision, use the vision model to describe the image/video, and return the messages with the image/video's description. Format: "agent" or "mcp:mcp_server_id"
Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // The assistant for processing audio (speech-to-text, text-to-speech). If the model doesn't support audio, use this to convert audio to text. Format: "agent" or "mcp:mcp_server_id"
Search string `json:"search,omitempty" yaml:"search,omitempty"` // The assistant for searching the knowledge, global web search. If not set, and the assistant enable the knowledge, it will search the result from the knowledge automatically.
Fetch string `json:"fetch,omitempty" yaml:"fetch,omitempty"` // The assistant for fetching the http/https/ftp/sftp/etc. file, and return the file's content. if not set, use the http process to fetch the file.
// Search-related processing tools (NLP)
Web string `json:"web,omitempty" yaml:"web,omitempty"` // Web search handler: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
@ -58,13 +59,14 @@ type Uses struct {
// System configures connectors for system agents
// ===============================
type System struct {
Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent
Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // Connector for __yao.prompt agent
NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent
Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent
Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent
Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // Connector for __yao.prompt agent
RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // Connector for __yao.robot_prompt agent
NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent
Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent
}
// Mention Structure

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,7 @@ package engine
import (
"fmt"
"log"
"os"
"regexp"
"strings"
@ -12,6 +13,7 @@ import (
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/agent"
robotapi "github.com/yaoapp/yao/agent/robot/api"
"github.com/yaoapp/yao/aigc"
"github.com/yaoapp/yao/api"
"github.com/yaoapp/yao/attachment"
@ -355,6 +357,17 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
warnings = append(warnings, Warning{Widget: "Agent", Error: err})
}
// Start Robot Agent System (async, non-blocking)
// This starts the robot scheduler for autonomous mode robots
go func() {
if err := robotapi.Start(); err != nil {
// Log warning but don't block application startup
// The robot system can operate without the manager running
// (API calls will fall back to direct database queries)
log.Printf("[Robot Agent] Warning: failed to start robot agent system: %v", err)
}
}()
for name, hook := range LoadHooks {
err = hook(cfg)
if err != nil {
@ -396,6 +409,13 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
func Unload() (err error) {
defer func() { err = exception.Catch(recover()) }()
// Stop Robot Agent System
if robotapi.IsRunning() {
if stopErr := robotapi.Stop(); stopErr != nil {
log.Printf("[Robot Agent] Warning: failed to stop robot agent system: %v", stopErr)
}
}
// Stop Runtime
err = runtime.Stop()

View file

@ -2,6 +2,7 @@ package agent
import (
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/agent/robot"
"github.com/yaoapp/yao/openapi/oauth/types"
)
@ -26,4 +27,8 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// Assistant Actions
// group.POST("/assistants/:id/call", agent.HandleAssistantCall) // POST /assistants/:id/call - Execute assistant API
// Robot routes - Attach as sub-router
// Routes: GET/POST /robots, GET/PUT/DELETE /robots/:id, GET /robots/:id/status
robot.Attach(group.Group("/robots"), oauth)
}

View file

@ -78,12 +78,24 @@ func ListAssistants(c *gin.Context) {
// Parse filter parameters
keywords := strings.TrimSpace(c.Query("keywords"))
typeParam := strings.TrimSpace(c.Query("type"))
if typeParam == "" {
typeParam = "assistant" // Default type
}
connector := strings.TrimSpace(c.Query("connector"))
assistantID := strings.TrimSpace(c.Query("assistant_id"))
// Parse types (multiple, comma-separated for IN query)
var types []string
if typesParam := c.Query("types"); typesParam != "" {
types = strings.Split(typesParam, ",")
// Trim spaces
for i, t := range types {
types[i] = strings.TrimSpace(t)
}
}
// Set default type only if neither type nor types is specified
if typeParam == "" && len(types) == 0 {
typeParam = "assistant" // Default type
}
// Parse assistant IDs (multiple)
var assistantIDs []string
if assistantIDsParam := c.Query("assistant_ids"); assistantIDsParam != "" {
@ -131,6 +143,7 @@ func ListAssistants(c *gin.Context) {
PageSize: pagesize,
Keywords: keywords,
Type: typeParam,
Types: types,
Connector: connector,
AssistantID: assistantID,
AssistantIDs: assistantIDs,

View file

@ -0,0 +1,840 @@
# Robot OpenAPI - Design Document
> Based on: `yao/agent/robot/` (Backend), `cui/packages/cui/pages/mission-control/` (Frontend)
> Gap Analysis: `yao/openapi/agent/robot/GAPS.md`
## 1. Overview
### 1.1 Purpose
Provide HTTP REST API endpoints for Robot Agent management, designed to support the Mission Control frontend UI.
### 1.2 Implementation Strategy
> **Low-risk phases first. Medium-risk features (Chat API, SSE Event Bus) can be deferred.**
| Phase | Risk | Features | Frontend Fallback |
|-------|------|----------|-------------------|
| 1. Core CRUD | 🟢 Low | List, Get, Create, Update, Delete | - |
| 2. Execution Management | 🟢 Low | List, Get, Control executions | - |
| 3. Results & Activities | 🟢 Low | Deliverables, Activity feed | - |
| 4. i18n | 🟢 Low | Locale parameter support | - |
| 5. Chat API | 🟡 Medium (Deferred) | Multi-turn conversation | Single-submit mode |
| 6. SSE Event Bus | 🟡 Medium (Deferred) | Real-time status streams | Polling every 3-5s |
### 1.3 Route Decision: `/v1/agent/robots`
**Analysis of existing `openapi/` route structure:**
| Package | Route | Description |
|---------|-------|-------------|
| `agent/` | `/v1/agent/assistants` | Assistant CRUD, info |
| `chat/` | `/v1/chat/completions` | Chat completions |
| `kb/` | `/v1/kb/collections` | Knowledge base |
| `job/` | `/v1/job/jobs` | Job management |
| `file/` | `/v1/file/*` | File operations |
| `user/` | `/v1/user/*` | User management |
| `team/` | `/v1/team/*` | Team management |
**Decision:** Put Robot routes under `/v1/agent/robots` because:
1. **Semantic Alignment**: Robot is a type of Agent (Autonomous Robot Agent), just like Assistant is a type of Agent
2. **Existing Pattern**: `openapi/agent/` already handles `/v1/agent/assistants`
3. **Logical Grouping**: Agent-related APIs grouped together
4. **Consistent Hierarchy**: `/v1/agent/{type}` pattern
**Route Comparison:**
| Option | Path | Verdict |
|--------|------|---------|
| ❌ `/v1/robots` | New top-level namespace | Inconsistent with agent grouping |
| ✅ `/v1/agent/robots` | Under agent namespace | Follows existing pattern |
| ❌ `/v1/members?type=robot` | Reuse members | Less intuitive for operations |
### 1.4 Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Frontend (Mission Control) │
│ cui/packages/cui/pages/mission-control/ │
└───────────────────────────────┬─────────────────────────────────────────┘
│ HTTP REST / SSE
┌─────────────────────────────────────────────────────────────────────────┐
│ OpenAPI Layer │
│ yao/openapi/agent/ │
│ - Routes: /v1/agent/assistants/* (existing) │
│ - Routes: /v1/agent/robots/* (NEW) │
│ - Auth: OAuth2 via Guard middleware │
│ - SSE: Real-time updates │
└───────────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Robot API Layer │
│ yao/agent/robot/api/ │
│ - Go functions: Get(), List(), Trigger(), etc. │
│ - Business logic │
└───────────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Robot Core │
│ yao/agent/robot/ │
│ - Manager, Executor, Cache, Pool, Store │
└─────────────────────────────────────────────────────────────────────────┘
```
### 1.5 Design Principles
1. **Layered Architecture**: OpenAPI layer only handles HTTP concerns (routing, request parsing, response formatting). Business logic stays in `robot/api/`.
2. **Consistent with Existing Patterns**: Follow `yao/openapi/agent/` conventions, extend existing agent package
3. **Incremental Implementation**: Start with core CRUD, then add real-time features
4. **Frontend-Backend Balance**: API design considers both frontend needs and backend capabilities
---
## 2. Differences Analysis
### 2.1 Frontend Expectations vs Backend Reality
| Feature | Frontend (API.md) | Backend (robot/api/) | Gap | Solution |
|---------|-------------------|----------------------|-----|----------|
| Robot List | `GET /v1/robots` with `name`, `description` | `List()` returns `types.Robot` | Field mapping needed | Map in OpenAPI layer |
| Robot Detail | `GET /v1/robots/:id` with full `config` | `Get()` returns Robot + Config | Need format conversion | Map to frontend format |
| Create Robot | POST with `work_mode` | Not implemented | New feature | Add `Create()` |
| Update Robot | PUT with partial update | Not implemented | New feature | Add `Update()` |
| Delete Robot | DELETE | Not implemented | New feature | Add `Remove()` |
| Trigger | Immediate execution | `Trigger()` returns sync result | Works | Wrap with SSE events |
| Intervene | Immediate intervention | `Intervene()` returns sync result | Works | Wrap with SSE events |
| Multi-turn Chat | Chat before execute | Not implemented | **Deferred** | Frontend uses single-submit |
| Results List | `/results` endpoint | No separate results API | New feature | Derive from executions |
| Activities | `/activities` endpoint | No activities tracking | New feature | Derive from executions |
| Real-time Stream | SSE `/stream` endpoints | No SSE support | **Deferred** | Frontend uses polling |
| i18n | `?locale=` query param | No i18n support | New feature | Add locale handling |
### 2.2 Field Mapping (Backend → Frontend API)
The `__yao.member` model already has the necessary fields, with different names:
| Frontend API | Backend DB (`__yao.member`) | Backend Go (`types.Robot`) | Mapping |
|--------------|----------------------------|---------------------------|---------|
| `member_id` | `member_id` | `MemberID` | Direct |
| `name` | `member_id` | `MemberID` | **Reuse** (slug-like identifier) |
| `display_name` | `display_name` | `DisplayName` | Direct |
| `description` | `bio` | Need to add `Bio` field | Map in OpenAPI layer |
| `email` | `robot_email` | `RobotEmail` | Direct |
**Required Backend Changes:**
1. Add `Bio` field to `types.Robot` struct
2. Add `bio` to `cache/load.go` memberFields
### 2.3 Type Differences
| Frontend Type | Backend Type | Solution |
|---------------|--------------|----------|
| `RobotState.name` | `Robot.MemberID` | Map `member_id` to `name` |
| `RobotState.description` | `Robot.Bio` (new) | Add field, map to `description` |
| `Execution.name` | Not in `types.Execution` | Derive from goals or input in OpenAPI layer |
| `Execution.current_task_name` | Not in `types.Execution` | Derive from current task in OpenAPI layer |
| `ResultFile` | No equivalent | New type in OpenAPI layer (derive from delivery) |
| `Activity` | No equivalent | New type in OpenAPI layer (derive from executions) |
---
## 3. API Endpoints
> **Base Path:** `/v1/agent/robots`
### 3.1 Robot Management
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| GET | /v1/agent/robots | `ListRobots` | List all robots |
| GET | /v1/agent/robots/:id | `GetRobot` | Get robot details |
| POST | /v1/agent/robots | `CreateRobot` | Create robot |
| PUT | /v1/agent/robots/:id | `UpdateRobot` | Update robot |
| DELETE | /v1/agent/robots/:id | `DeleteRobot` | Delete robot |
### 3.2 Execution Management
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| GET | /v1/agent/robots/:id/executions | `ListExecutions` | List executions |
| GET | /v1/agent/robots/:id/executions/:exec_id | `GetExecution` | Get execution detail |
| POST | /v1/agent/robots/:id/trigger | `TriggerRobot` | Trigger execution (SSE) |
| POST | /v1/agent/robots/:id/intervene | `InterveneRobot` | Intervene execution (SSE) |
| POST | /v1/agent/robots/:id/executions/:exec_id/pause | `PauseExecution` | Pause execution |
| POST | /v1/agent/robots/:id/executions/:exec_id/resume | `ResumeExecution` | Resume execution |
| POST | /v1/agent/robots/:id/executions/:exec_id/cancel | `CancelExecution` | Cancel execution |
| POST | /v1/agent/robots/:id/executions/:exec_id/retry | `RetryExecution` | Retry execution |
### 3.3 Results Management
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| GET | /v1/agent/robots/:id/results | `ListResults` | List deliverables |
| GET | /v1/agent/robots/:id/results/:result_id | `GetResult` | Get deliverable detail |
### 3.4 Activities & Real-time
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| GET | /v1/agent/robots/activities | `ListActivities` | List recent activities |
| GET | /v1/agent/robots/stream | `StreamRobots` | Robot status SSE |
| GET | /v1/agent/robots/:id/executions/:exec_id/stream | `StreamExecution` | Execution progress SSE |
---
## 4. Response Types
### 4.1 RobotResponse (for list and detail)
```go
// RobotResponse - formatted robot for API response
// Maps backend fields to frontend expected format
type RobotResponse struct {
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
Name string `json:"name"` // From Robot.MemberID (slug-like identifier)
DisplayName string `json:"display_name"` // From Robot.DisplayName
Description string `json:"description,omitempty"` // From Robot.Bio
Status string `json:"status"` // idle | working | paused | error | maintenance
Running int `json:"running"` // Current running count
MaxRunning int `json:"max_running"` // From Config.Quota.Max
LastRun *string `json:"last_run,omitempty"` // ISO timestamp
NextRun *string `json:"next_run,omitempty"` // ISO timestamp
RunningIDs []string `json:"running_ids,omitempty"` // Execution IDs
Config *ConfigResponse `json:"config,omitempty"` // Full config (for detail)
}
// NewRobotResponse converts backend Robot to API response
func NewRobotResponse(robot *types.Robot) *RobotResponse {
return &RobotResponse{
MemberID: robot.MemberID,
TeamID: robot.TeamID,
Name: robot.MemberID, // Use MemberID as unique identifier
DisplayName: robot.DisplayName,
Description: robot.Bio, // Map Bio to Description
Status: string(robot.Status),
// ... other fields
}
}
```
### 4.2 ConfigResponse (robot config)
```go
// ConfigResponse - formatted config for API response
type ConfigResponse struct {
Identity *IdentityConfig `json:"identity,omitempty"`
Clock *ClockConfig `json:"clock,omitempty"`
Events []EventConfig `json:"events,omitempty"`
Quota *QuotaConfig `json:"quota,omitempty"`
Resources *ResourcesConfig `json:"resources,omitempty"`
Delivery *DeliveryConfig `json:"delivery,omitempty"`
Triggers *TriggersConfig `json:"triggers,omitempty"`
Learn *LearnConfig `json:"learn,omitempty"`
Executor *ExecutorConfig `json:"executor,omitempty"`
}
```
### 4.3 ExecutionResponse
```go
// ExecutionResponse - formatted execution for API response
type ExecutionResponse struct {
ID string `json:"id"`
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
TriggerType string `json:"trigger_type"`
StartTime string `json:"start_time"`
EndTime *string `json:"end_time,omitempty"`
Status string `json:"status"`
Phase string `json:"phase"`
Error *string `json:"error,omitempty"`
JobID string `json:"job_id"`
Name string `json:"name,omitempty"` // Localized execution name
CurrentTaskName string `json:"current_task_name,omitempty"` // Localized current task
Goals *GoalsResponse `json:"goals,omitempty"`
Tasks []TaskResponse `json:"tasks,omitempty"`
Current *CurrentState `json:"current,omitempty"`
Delivery *DeliveryResult `json:"delivery,omitempty"`
}
```
### 4.4 ResultResponse
```go
// ResultResponse - deliverable file for Results tab
type ResultResponse struct {
ID string `json:"id"`
MemberID string `json:"member_id"`
ExecutionID string `json:"execution_id"`
Name string `json:"name"`
Type string `json:"type"` // pdf, xlsx, csv, json, md
Size int64 `json:"size"` // bytes
CreatedAt string `json:"created_at"`
TriggerType string `json:"trigger_type,omitempty"`
ExecutionName string `json:"execution_name,omitempty"`
}
```
### 4.5 ActivityResponse
```go
// ActivityResponse - activity item
type ActivityResponse struct {
ID string `json:"id"`
Type string `json:"type"` // completed | file | error | started | paused
MemberID string `json:"member_id"`
RobotName string `json:"robot_name"` // Localized
Title string `json:"title"` // Localized
Description string `json:"description,omitempty"` // Localized
FileID string `json:"file_id,omitempty"`
Timestamp string `json:"timestamp"`
}
```
---
## 5. Request Types
### 5.1 CreateRobotRequest
```go
// CreateRobotRequest - create robot request
type CreateRobotRequest struct {
Locale string `json:"locale,omitempty"` // zh-CN | en-US
Name string `json:"name"` // Unique identifier
DisplayName string `json:"display_name"` // Display name
Email string `json:"email,omitempty"` // Robot email
ManagerID string `json:"manager_id,omitempty"` // Manager user ID
WorkMode string `json:"work_mode"` // autonomous | on-demand
Identity *IdentityConfig `json:"identity"`
Resources *ResourcesConfig `json:"resources,omitempty"`
}
```
### 5.2 UpdateRobotRequest
```go
// UpdateRobotRequest - update robot request
type UpdateRobotRequest struct {
Locale string `json:"locale,omitempty"`
DisplayName *string `json:"display_name,omitempty"`
Config *ConfigResponse `json:"config,omitempty"` // Partial update supported
}
```
### 5.3 TriggerRequest (SSE)
```go
// TriggerRequest - trigger robot execution
type TriggerRequest struct {
Locale string `json:"locale,omitempty"`
Messages []Message `json:"messages"`
Attachments []Attachment `json:"attachments,omitempty"`
}
// Message - chat message
type Message struct {
Role string `json:"role"` // user | assistant
Content string `json:"content"`
}
// Attachment - file attachment
type Attachment struct {
File string `json:"file"` // __yao.attachment://fileID
Name string `json:"name,omitempty"`
}
```
### 5.4 InterveneRequest (SSE)
```go
// InterveneRequest - intervene during execution
type InterveneRequest struct {
Locale string `json:"locale,omitempty"`
ExecutionID string `json:"execution_id"`
Action string `json:"action"` // task.add | goal.adjust | instruct
Messages []Message `json:"messages"`
Priority string `json:"priority,omitempty"` // high | normal | low
Position string `json:"position,omitempty"` // first | last | next | at
}
```
---
## 6. Deferred Features
### 6.1 Multi-turn Chat API (Phase 5 - Deferred)
> **Risk Level:** 🟡 Medium - Requires new stateful component
> **Frontend Fallback:** Single-submit mode (user input → immediate execution)
The frontend `ChatDrawer` component expects multi-turn conversation before execution:
```
User: "Help me analyze competitor pricing"
Robot: "Got it. Which competitors?"
User: "Focus on Company A and B"
Robot: "Understood. Ready to start?"
User clicks [Confirm] → Execution starts
```
**Current backend behavior:** `Trigger()` immediately submits to execution pool.
**Deferred implementation:**
```
POST /v1/agent/robots/:id/chat
{
"conversation_id": "conv_001", // For continuing conversation
"messages": [{ "role": "user", "content": "..." }]
}
Response (SSE):
event: message
data: {"role": "assistant", "content": "..."}
event: state
data: {"conversation_id": "conv_001", "ready_to_execute": false}
```
**For now:** Frontend can skip chat flow, directly call `/trigger` with user message.
### 6.2 SSE Event Bus (Phase 6 - Deferred)
> **Risk Level:** 🟡 Medium - Requires modification of executor/manager
> **Frontend Fallback:** Polling (GET /executions every 3-5 seconds)
Real-time status updates via SSE require an event bus integrated with:
- Manager (robot status changes)
- Executor (execution progress)
**For now:** Frontend uses polling to refresh status.
---
## 7. SSE Events
### 7.1 Trigger/Intervene SSE Events
```
event: received
data: {"message": "Task received, creating execution..."}
event: execution
data: {"execution_id": "exec_002", "status": "pending"}
event: message
data: {"role": "assistant", "content": "好的,我开始处理..."}
event: phase
data: {"phase": "goals", "message": "正在生成目标..."}
event: complete
data: {"execution_id": "exec_002", "status": "running"}
event: error
data: {"error": "Something went wrong"}
```
### 7.2 Robot Stream SSE Events (Phase 6 - Deferred)
```
event: robot_status
data: {"member_id": "robot_001", "status": "working", "running": 1}
event: execution_start
data: {"member_id": "robot_001", "execution_id": "exec_001", "name": "每日报表生成"}
event: execution_complete
data: {"member_id": "robot_001", "execution_id": "exec_001", "status": "completed"}
event: activity
data: {"id": "act_001", "type": "completed", "member_id": "robot_001", ...}
```
### 7.3 Execution Stream SSE Events (Phase 6 - Deferred)
```
event: phase
data: {"phase": "tasks", "progress": "2/5 tasks"}
event: task_start
data: {"task_id": "task_002", "order": 2}
event: task_complete
data: {"task_id": "task_002", "status": "completed"}
event: message
data: {"role": "assistant", "content": "正在分析数据..."}
event: delivery
data: {"summary": "...", "attachments": [...]}
event: complete
data: {"status": "completed"}
event: error
data: {"error": "Something went wrong", "phase": "run"}
```
---
## 8. i18n Support
### 8.1 Locale Detection
Priority order:
1. Query parameter: `?locale=zh-CN`
2. Request body field: `locale: "zh-CN"`
3. Accept-Language header
4. Default: `en-US`
### 8.2 Localized Fields
| Response Type | Localized Fields |
|---------------|------------------|
| RobotResponse | display_name, description |
| ExecutionResponse | name, current_task_name |
| TaskResponse | (none - tasks use executor_id) |
| ResultResponse | name, execution_name |
| ActivityResponse | robot_name, title, description |
---
## 9. Authentication & Authorization
### 9.1 Guard Middleware
All endpoints require OAuth2 authentication via `oauth.Guard` middleware.
```go
// In router registration
router.Use(oauth.Guard())
```
### 9.2 Permission Checks
| Endpoint | Required Scope |
|----------|----------------|
| GET /robots | `robots:read` |
| POST /robots | `robots:write` |
| PUT/DELETE /robots/:id | `robots:write` + ownership check |
| Trigger/Intervene | `robots:execute` |
| Stream endpoints | `robots:read` |
### 9.3 Team Isolation
Robots are team-scoped. Users can only access robots in their team.
```go
func checkTeamAccess(ctx context.Context, memberID string) error {
auth := oauth.GetAuthorized(ctx)
robot, _ := robotapi.Get(memberID)
if robot.TeamID != auth.TeamID {
return errors.New("access denied")
}
return nil
}
```
---
## 10. File Structure
### 10.1 Backend Store + API Layers
```
yao/agent/robot/
├── store/ # Store Layer (Core CRUD)
│ ├── store.go # Common interfaces
│ ├── execution.go # ExecutionStore (EXISTS)
│ └── robot.go # RobotStore (NEW)
├── api/ # API Layer (Thin wrappers)
│ ├── robot.go # Get, List, Create, Update, Remove
│ ├── execution.go # Execution management
│ ├── trigger.go # Trigger, Intervene
│ ├── results.go # ListResults, GetResult (NEW)
│ └── activities.go # ListActivities (NEW)
├── types/ # Type definitions
│ └── robot.go # Add Bio field
└── cache/ # Cache Layer
└── load.go # Add bio to memberFields
```
### 10.2 OpenAPI Layer
**Decision: Sub-package under `openapi/agent/`**
Robot logic is complex enough to warrant its own package. This keeps code organized and follows the pattern used by other complex modules.
```
yao/openapi/agent/
├── agent.go # Main route registration (MODIFY: add robot.Attach)
├── assistant.go # Assistant handlers (existing)
├── filter.go # Query filtering (existing)
├── models.go # LLM models (existing)
├── types.go # Types (existing)
└── robot/ # Robot sub-package (NEW)
├── DESIGN.md # This document ✅
├── TODO.md # Implementation plan ✅
├── GAPS.md # Gap analysis ✅
├── robot.go # Route registration (Attach function)
├── types.go # Request/Response types
├── list.go # GET /v1/agent/robots
├── detail.go # GET/POST/PUT/DELETE /v1/agent/robots/:id
├── execution.go # Execution list/detail/control handlers
├── trigger.go # POST /trigger, POST /intervene (SSE)
├── results.go # GET /results, GET /results/:id
├── activities.go # GET /activities
├── stream.go # GET /stream, GET /executions/:id/stream (SSE)
├── filter.go # Query param parsing helpers
└── utils.go # Locale, time formatting utilities
```
**Route Registration (in `openapi/agent/agent.go`):**
```go
import "github.com/yaoapp/yao/openapi/agent/robot"
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.Use(oauth.Guard)
// Assistant routes (existing)
group.GET("/assistants", ListAssistants)
group.POST("/assistants", CreateAssistant)
// ...
// Robot routes (NEW)
robot.Attach(group.Group("/robots"), oauth)
}
```
**Robot Route Registration (`robot/robot.go`):**
```go
package robot
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// Robot CRUD
group.GET("", ListRobots)
group.POST("", CreateRobot)
group.GET("/:id", GetRobot)
group.PUT("/:id", UpdateRobot)
group.DELETE("/:id", DeleteRobot)
// Activities (before :id to avoid conflict)
group.GET("/activities", ListActivities)
group.GET("/stream", StreamRobots)
// Execution management
group.GET("/:id/executions", ListExecutions)
group.GET("/:id/executions/:exec_id", GetExecution)
group.GET("/:id/executions/:exec_id/stream", StreamExecution)
group.POST("/:id/executions/:exec_id/pause", PauseExecution)
group.POST("/:id/executions/:exec_id/resume", ResumeExecution)
group.POST("/:id/executions/:exec_id/cancel", CancelExecution)
group.POST("/:id/executions/:exec_id/retry", RetryExecution)
// Trigger & Intervene (SSE)
group.POST("/:id/trigger", TriggerRobot)
group.POST("/:id/intervene", InterveneRobot)
// Results
group.GET("/:id/results", ListResults)
group.GET("/:id/results/:result_id", GetResult)
}
```
---
## 11. Error Handling
### 11.1 Error Response Format
```json
{
"error": {
"code": "ROBOT_NOT_FOUND",
"message": "Robot not found",
"details": {
"member_id": "robot_001"
}
}
}
```
### 11.2 Error Codes
| Code | HTTP Status | Description |
|------|-------------|-------------|
| ROBOT_NOT_FOUND | 404 | Robot does not exist |
| EXECUTION_NOT_FOUND | 404 | Execution does not exist |
| ROBOT_BUSY | 409 | Robot at max capacity |
| TRIGGER_DISABLED | 403 | Trigger type disabled |
| EXECUTION_NOT_RUNNING | 400 | Cannot pause/resume non-running execution |
| INVALID_REQUEST | 400 | Request validation failed |
| UNAUTHORIZED | 401 | Not authenticated |
| FORBIDDEN | 403 | No permission |
---
## 12. Implementation Notes
### 12.1 Backend Architecture: Store + API Layers
> **Principle:** Store layer handles database CRUD, API layer handles business logic.
> This enables reuse across Golang API, JSAPI, and Yao Process.
```
Consumers (Golang API / JSAPI / Yao Process)
┌─────────────────────────────────────────┐
│ API Layer (robot/api/) │
│ Thin wrappers: validation, cache ops │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Store Layer (robot/store/) │
│ Core CRUD: RobotStore, ExecutionStore │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Model Layer (__yao.member) │
└─────────────────────────────────────────┘
```
### 12.2 Store Layer Extensions
**File: `store/robot.go` (NEW)** - Core Robot CRUD
```go
type RobotStore struct {
modelID string // "__yao.member"
}
func (s *RobotStore) Save(ctx context.Context, record *RobotRecord) error
func (s *RobotStore) Get(ctx context.Context, memberID string) (*RobotRecord, error)
func (s *RobotStore) List(ctx context.Context, opts *ListOptions) ([]*RobotRecord, error)
func (s *RobotStore) Delete(ctx context.Context, memberID string) error
func (s *RobotStore) UpdateConfig(ctx context.Context, memberID string, config map[string]interface{}) error
```
**File: `store/execution.go` (extend)**
```go
func (s *ExecutionStore) ListResults(ctx context.Context, memberID string, opts *ResultsQuery) ([]*ResultRecord, error)
func (s *ExecutionStore) GetResult(ctx context.Context, resultID string) (*ResultRecord, error)
func (s *ExecutionStore) ListActivities(ctx context.Context, opts *ActivityQuery) ([]*ActivityRecord, error)
```
### 12.3 API Layer Extensions
**File: `api/robot.go` (extend)** - Thin wrappers
```go
// Create - calls store.RobotStore.Save() + cache refresh
func Create(ctx *types.Context, teamID string, req *CreateRobotRequest) (*types.Robot, error)
// Update - calls store.RobotStore.UpdateConfig() + cache refresh
func Update(ctx *types.Context, memberID string, req *UpdateRobotRequest) (*types.Robot, error)
// Remove - calls store.RobotStore.Delete() + cache invalidate
func Remove(ctx *types.Context, memberID string) error
```
**File: `api/results.go` (NEW)**
```go
func ListResults(ctx *types.Context, memberID string, query *ResultQuery) (*ResultsResult, error)
func GetResult(ctx *types.Context, resultID string) (*ResultFile, error)
```
**File: `api/activities.go` (NEW)**
```go
func ListActivities(ctx *types.Context, query *ActivityQuery) (*ActivitiesResult, error)
```
### 12.4 Localization
Add `Locale` parameter support for localized responses.
### 12.5 SSE Implementation
Use standard Go SSE pattern:
```go
func streamHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, _ := w.(http.Flusher)
for event := range events {
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, event.Data)
flusher.Flush()
}
}
```
### 12.6 Localization Strategy
- Store display names in `__yao.member.display_name` (single language) initially
- Future: Add `display_name_cn`, `display_name_en` or use JSON `{"en": "...", "cn": "..."}`
- Execution names derived from goals or input message
- Activities derive titles from execution data
---
## 13. API Base Path Decision
Based on analysis of existing `openapi/` structure:
| Option | Path | Pros | Cons |
|--------|------|------|------|
| ❌ A | `/v1/robots` | Shorter path | New namespace, inconsistent |
| ✅ B | `/v1/agent/robots` | Groups with agent APIs, consistent | Longer path |
| ❌ C | `/v1/members?type=robot` | Uses existing members | Less intuitive |
**Decision**: Use `/v1/agent/robots` as base path.
**Rationale:**
1. `openapi/agent/` already exists with `/v1/agent/assistants`
2. Robot is conceptually an Agent type (Autonomous Robot Agent)
3. Follows the established pattern: `/v1/agent/{agent-type}`
4. Keeps agent-related APIs logically grouped
**Frontend Impact:**
- Update `cui/packages/cui/pages/mission-control/API.md` base path from `/v1/robots` to `/v1/agent/robots`
- Minimal code change (just update base URL constant)
---
## 14. References
- Frontend API Requirements: `cui/packages/cui/pages/mission-control/API.md`
- Backend Robot Design: `yao/agent/robot/DESIGN.md`
- Backend Technical Spec: `yao/agent/robot/TECHNICAL.md`
- Existing OpenAPI Patterns: `yao/openapi/kb/`, `yao/openapi/chat/`

846
openapi/agent/robot/GAPS.md Normal file
View file

@ -0,0 +1,846 @@
# Robot OpenAPI - Gap Analysis
> This document analyzes the gaps between existing backend implementation and frontend API requirements.
> Generated from reviewing: `yao/agent/robot/`, `yao/openapi/agent/`, `cui/packages/cui/pages/mission-control/`
---
## Summary
| Category | Risk | Status | Items to Implement |
|----------|------|--------|-------------------|
| Backend Types | 🟢 Low | 🟡 Partial | 1 field to add (`Bio`), 2 fields for Execution |
| Backend Cache | 🟢 Low | 🟡 Partial | Add `bio` to memberFields in `cache/load.go` |
| Backend API | 🟢 Low | 🟡 Partial | 7 functions missing (CRUD + Results + Activities) |
| OpenAPI Layer | 🟢 Low | ⬜ New | 19 endpoints, response type mapping |
| i18n | 🟢 Low | ⬜ New | Locale parameter support |
| **Chat API** | 🟡 Medium | ⬜ Deferred | Multi-turn conversation (frontend fallback: single-submit) |
| **SSE Infrastructure** | 🟡 Medium | ⬜ Deferred | Event bus + SSE handlers (frontend fallback: polling) |
### Key Field Mapping (Backend → Frontend)
| Frontend API | Backend DB (`__yao.member`) | Backend Go (`types.Robot`) |
|--------------|----------------------------|---------------------------|
| `name` | `member_id` | `MemberID` |
| `display_name` | `display_name` | `DisplayName` |
| `description` | `bio` | Need to add `Bio` field |
| `email` | `robot_email` | `RobotEmail` |
---
## 1. Backend Types Gaps (`yao/agent/robot/types/`)
### 1.1 Field Mapping (Backend → Frontend API)
The `__yao.member` model already has the necessary fields, but with different names:
| Frontend API Field | Backend DB Field | Status | Notes |
|-------------------|------------------|--------|-------|
| `member_id` | `member_id` | ✅ Exists | Global unique identifier |
| `name` | `member_id` | ✅ **Reuse** | Frontend expects a slug like `sales-analyst`, can use `member_id` |
| `display_name` | `display_name` | ✅ Exists | Localized display name |
| `description` | `bio` | ✅ Exists | `bio` field in `__yao.member` is the robot description |
**Backend Robot struct (`types/robot.go`):**
```go
type Robot struct {
MemberID string `json:"member_id"` // ✅ Exists
TeamID string `json:"team_id"` // ✅ Exists
DisplayName string `json:"display_name"` // ✅ Exists
SystemPrompt string `json:"system_prompt"`// ✅ Exists
// ...
}
```
**Missing fields to add to Robot struct:**
```go
type Robot struct {
// ... existing fields ...
Bio string `json:"bio"` // NEW: from __yao.member.bio (robot description)
}
```
**OpenAPI Response Mapping:**
```go
// In OpenAPI layer, map backend fields to frontend expected format
type RobotResponse struct {
MemberID string `json:"member_id"`
Name string `json:"name"` // Use MemberID as unique slug
DisplayName string `json:"display_name"`
Description string `json:"description"` // Map from Robot.Bio
// ...
}
```
### 1.2 Cache/Load Update Needed
Update `cache/load.go` to fetch `bio` field:
```go
var memberFields = []interface{}{
"id",
"member_id",
"team_id",
"display_name",
"bio", // ADD THIS
"system_prompt",
"robot_status",
"autonomous_mode",
"robot_config",
"robot_email", // Already there
}
```
### 1.3 Missing Fields in `Execution` struct
| Field | Type | Location | Description |
|-------|------|----------|-------------|
| `Name` | `string` | `types/robot.go` | Derived from goals or human input, for UI display |
| `CurrentTaskName` | `string` | `types/robot.go` | What the agent is doing RIGHT NOW |
**Required (add to Execution struct):**
```go
type Execution struct {
// ... existing fields ...
Name string `json:"name,omitempty"` // NEW: execution name for UI
CurrentTaskName string `json:"current_task_name,omitempty"` // NEW: current task description
}
```
> **Note:** These can be derived in the OpenAPI layer from existing fields:
> - `Name`: Derive from `Goals.Content` first line or `Input.Messages[0].Content`
> - `CurrentTaskName`: Derive from `Current.Task` executor info or progress
### 1.4 New Types Needed
#### Activity Type (for Activity API)
> **Note:** Activity can be derived from execution history without new storage.
> These types go in OpenAPI response layer, not core types.
```go
// openapi/agent/robot/types.go (API response types)
// ActivityType - activity type enum
type ActivityType string
const (
ActivityCompleted ActivityType = "completed"
ActivityFile ActivityType = "file"
ActivityError ActivityType = "error"
ActivityStarted ActivityType = "started"
ActivityPaused ActivityType = "paused"
)
// ActivityResponse - activity item for UI
type ActivityResponse struct {
ID string `json:"id"`
Type ActivityType `json:"type"`
MemberID string `json:"member_id"`
RobotName string `json:"robot_name"` // Localized
Title string `json:"title"` // Localized
Description string `json:"description,omitempty"` // Localized
FileID string `json:"file_id,omitempty"`
Timestamp string `json:"timestamp"` // ISO format
}
```
#### ResultFile Type (for Results API)
> **Note:** Results are derived from `execution.delivery.content.attachments`.
> No separate storage needed.
```go
// openapi/agent/robot/types.go (API response types)
// ResultFileResponse - deliverable file for Results Tab
type ResultFileResponse struct {
ID string `json:"id"` // attachment index or file ID
MemberID string `json:"member_id"`
ExecutionID string `json:"execution_id"`
Name string `json:"name"` // From attachment.Title
Type string `json:"type"` // Derived from file extension
Size int64 `json:"size"` // From file system
CreatedAt string `json:"created_at"` // Execution end time
TriggerType string `json:"trigger_type,omitempty"`
ExecutionName string `json:"execution_name,omitempty"` // Derived
}
```
---
## 2. Multi-turn Conversation Gap (Critical)
### 2.1 Frontend Expectation
The frontend `ChatDrawer` component expects **multi-turn conversation** before execution starts:
```
┌─────────────────────────────────────────────────────────────────┐
│ ASSIGN TASK DRAWER (ChatDrawer) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User: "Help me analyze competitor pricing" │
│ ↓ │
│ Robot: "Got it. Which competitors? Any specific metrics?" │
│ ↓ │
│ User: "Focus on Company A and B, compare pricing tiers" │
│ ↓ │
│ Robot: "Understood. I'll analyze A and B pricing tiers. │
│ Ready to start?" │
│ ↓ │
│ User clicks [Confirm] → Execution starts │
│ │
└─────────────────────────────────────────────────────────────────┘
```
**Key Flow:**
1. User sends message → Backend returns assistant response
2. User can continue conversation (refine task)
3. User confirms → Execution actually starts
### 2.2 Current Backend Implementation
```go
// api/trigger.go - Current behavior
func Trigger(ctx *types.Context, memberID string, req *TriggerRequest) (*TriggerResult, error) {
// Immediately submits to execution pool
// No conversation state, no confirmation step
}
```
**Problem:** Backend triggers execution immediately on first message. No multi-turn conversation support.
### 2.3 Gap Analysis
| Feature | Frontend Expects | Backend Has |
|---------|------------------|-------------|
| Multi-turn chat | ✅ Yes | ❌ No |
| Conversation state | ✅ Yes | ❌ No |
| Confirm before execute | ✅ Yes | ❌ No |
| SSE for each message | ✅ Yes | ❌ No |
### 2.4 Required New API
**Option A: Chat API (Recommended)**
```
POST /v1/agent/robots/:id/chat
```
**Request:**
```json
{
"conversation_id": "conv_001", // Optional, for continuing conversation
"messages": [
{ "role": "user", "content": "Help me analyze competitor pricing" }
],
"attachments": []
}
```
**Response (SSE):**
```
event: message
data: {"role": "assistant", "content": "Got it. Which competitors?"}
event: state
data: {"conversation_id": "conv_001", "ready_to_execute": false}
```
**Then Trigger with conversation:**
```
POST /v1/agent/robots/:id/trigger
{
"conversation_id": "conv_001", // References chat history
"confirm": true
}
```
**Option B: Extend Trigger API**
Add `confirm` parameter to trigger:
```json
{
"messages": [...],
"confirm": false // false = chat mode, true = execute
}
```
### 2.5 Backend Implementation Needed
1. **Conversation Store** - Store chat history temporarily
```go
// store/conversation.go (NEW)
type ConversationStore interface {
Create(memberID string, messages []Message) (conversationID string, error)
Append(conversationID string, messages []Message) error
Get(conversationID string) (*Conversation, error)
Delete(conversationID string) error // Auto-cleanup after execution
}
```
2. **Chat Handler** - Process messages, return assistant response
```go
// api/chat.go (NEW)
func Chat(ctx *types.Context, memberID string, req *ChatRequest) (*ChatResponse, error) {
// 1. Get or create conversation
// 2. Call LLM for response (using robot's system prompt)
// 3. Store updated conversation
// 4. Return assistant message + conversation_id
}
```
3. **Trigger Extension** - Support conversation_id
```go
// api/trigger.go (MODIFY)
type TriggerRequest struct {
// ... existing fields ...
ConversationID string `json:"conversation_id,omitempty"` // NEW
}
```
### 2.6 Same for Intervention
`GuideExecutionDrawer` also uses `ChatDrawer` and expects the same multi-turn behavior for intervention.
---
## 3. Backend Architecture: Store + API Layers
### 3.1 Architecture Decision
> **Principle:** Store layer handles database CRUD, API layer handles business logic.
> This enables reuse across Golang API, JSAPI, and Yao Process.
```
┌──────────────────────────────────────────────────────────────────────┐
│ Consumers │
├──────────────────────────────────────────────────────────────────────┤
│ Golang API (robot/api) │ JSAPI (JS Runtime) │ Yao Process │
└──────────────────────────────┴───────────────────────┴───────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ API Layer (robot/api/) │
│ Business logic, parameter validation, cache invalidation │
│ - Thin wrappers that call store layer │
│ - Reusable across all consumers │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Store Layer (robot/store/) │
│ Pure database CRUD, no business logic │
│ - RobotStore: Robot member CRUD (NEW) │
│ - ExecutionStore: Execution records (EXISTS) │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Model Layer (__yao.member, etc.) │
└──────────────────────────────────────────────────────────────────────┘
```
### 3.2 Store Layer: Missing Functions
**File: `store/robot.go` (NEW)** - Core CRUD implementation
| Function | Status | Description |
|----------|--------|-------------|
| `RobotStore.Save()` | ⬜ Missing | Create or update robot member |
| `RobotStore.Get()` | ⬜ Missing | Get robot by member_id |
| `RobotStore.List()` | ⬜ Missing | List robots with filters |
| `RobotStore.Delete()` | ⬜ Missing | Delete robot member |
| `RobotStore.UpdateConfig()` | ⬜ Missing | Update robot config only |
**File: `store/execution.go` (extend)**
| Function | Status | Description |
|----------|--------|-------------|
| `ExecutionStore.ListResults()` | ⬜ Missing | Query deliverables from executions |
| `ExecutionStore.GetResult()` | ⬜ Missing | Get single deliverable |
| `ExecutionStore.ListActivities()` | ⬜ Missing | Derive activities from history |
### 3.3 API Layer: Missing Functions
**File: `api/robot.go` (extend)** - Thin wrappers calling store
| Function | Status | Description |
|----------|--------|-------------|
| `Create()` | ⬜ Missing | Call `store.RobotStore.Save()` + cache refresh |
| `Update()` | ⬜ Missing | Call `store.RobotStore.UpdateConfig()` + cache refresh |
| `Remove()` | ⬜ Missing | Call `store.RobotStore.Delete()` + cache invalidate |
**File: `api/results.go` (NEW)** - Thin wrappers
| Function | Status | Description |
|----------|--------|-------------|
| `ListResults()` | ⬜ Missing | Call `store.ExecutionStore.ListResults()` |
| `GetResult()` | ⬜ Missing | Call `store.ExecutionStore.GetResult()` |
**File: `api/activities.go` (NEW)** - Thin wrappers
| Function | Status | Description |
|----------|--------|-------------|
| `ListActivities()` | ⬜ Missing | Call `store.ExecutionStore.ListActivities()` |
**File: `api/execution.go` (extend)**
| Function | Status | Description |
|----------|--------|-------------|
| `RetryExecution()` | ⬜ Missing | Re-trigger with same input |
### 3.4 Existing Functions (Already implemented)
**Store Layer (`store/`):**
| Function | File | Status |
|----------|------|--------|
| `ExecutionStore.Save()` | `execution.go` | ✅ Exists |
| `ExecutionStore.Get()` | `execution.go` | ✅ Exists |
| `ExecutionStore.List()` | `execution.go` | ✅ Exists |
| `ExecutionStore.Delete()` | `execution.go` | ✅ Exists |
| `ExecutionStore.UpdatePhase()` | `execution.go` | ✅ Exists |
| `ExecutionStore.UpdateStatus()` | `execution.go` | ✅ Exists |
**API Layer (`api/`):**
| Function | File | Status |
|----------|------|--------|
| `List()` | `robot.go` | ✅ Exists |
| `Get()` | `robot.go` | ✅ Exists |
| `GetStatus()` | `robot.go` | ✅ Exists |
| `Trigger()` | `trigger.go` | ✅ Exists |
| `Intervene()` | `trigger.go` | ✅ Exists |
| `GetExecutions()` | `execution.go` | ✅ Exists |
| `GetExecution()` | `execution.go` | ✅ Exists |
| `PauseExecution()` | `execution.go` | ✅ Exists |
| `ResumeExecution()` | `execution.go` | ✅ Exists |
| `StopExecution()` | `execution.go` | ✅ Exists |
### 3.5 Code Examples
**Store Layer (`store/robot.go`):**
```go
// RobotStore - persistent storage for robot members
type RobotStore struct {
modelID string
}
func NewRobotStore() *RobotStore {
return &RobotStore{modelID: "__yao.member"}
}
// Save creates or updates a robot member record
func (s *RobotStore) Save(ctx context.Context, record *RobotRecord) error
// Get retrieves a robot by member_id
func (s *RobotStore) Get(ctx context.Context, memberID string) (*RobotRecord, error)
// List retrieves robots with filters
func (s *RobotStore) List(ctx context.Context, opts *ListOptions) ([]*RobotRecord, error)
// Delete removes a robot member
func (s *RobotStore) Delete(ctx context.Context, memberID string) error
```
**API Layer (`api/robot.go`):**
```go
// Create creates a new robot member (thin wrapper)
func Create(ctx *types.Context, teamID string, req *CreateRobotRequest) (*types.Robot, error) {
// 1. Validate request
// 2. Call store.RobotStore.Save()
// 3. Refresh cache
// 4. Return robot
}
// Update updates robot config (thin wrapper)
func Update(ctx *types.Context, memberID string, req *UpdateRobotRequest) (*types.Robot, error) {
// 1. Validate request
// 2. Call store.RobotStore.UpdateConfig()
// 3. Refresh cache
// 4. Return updated robot
}
// Remove deletes a robot member (thin wrapper)
func Remove(ctx *types.Context, memberID string) error {
// 1. Check permissions
// 2. Call store.RobotStore.Delete()
// 3. Invalidate cache
}
```
---
## 4. OpenAPI Layer (`yao/openapi/agent/robot/`)
### 4.1 Files to Create
```
yao/openapi/agent/robot/
├── DESIGN.md # ✅ Exists
├── TODO.md # ✅ Exists
├── GAPS.md # ✅ This file
├── robot.go # Route registration
├── types.go # Request/Response types
├── list.go # GET /v1/agent/robots
├── detail.go # GET/POST/PUT/DELETE /v1/agent/robots/:id
├── execution.go # Execution list/detail/control
├── trigger.go # Trigger/Intervene (SSE)
├── results.go # Results endpoints
├── activities.go # Activities endpoint
├── stream.go # Real-time SSE streams
├── filter.go # Query param parsing
└── utils.go # Locale, time formatting
```
### 4.2 Endpoints to Implement
#### Robot CRUD (5 endpoints)
| Endpoint | Handler | Backend API |
|----------|---------|-------------|
| `GET /robots` | `ListRobots` | `api.List()` ✅ |
| `GET /robots/:id` | `GetRobot` | `api.Get()` + `api.GetStatus()` ✅ |
| `POST /robots` | `CreateRobot` | `api.Create()` ⬜ |
| `PUT /robots/:id` | `UpdateRobot` | `api.Update()` ⬜ |
| `DELETE /robots/:id` | `DeleteRobot` | `api.Remove()` ⬜ |
#### Chat & Execution Management (9 endpoints)
| Endpoint | Handler | Backend API |
|----------|---------|-------------|
| `POST /robots/:id/chat` | `ChatWithRobot` | `api.Chat()`**NEW - Multi-turn conversation** |
| `GET /robots/:id/executions` | `ListExecutions` | `api.GetExecutions()` ✅ |
| `GET /robots/:id/executions/:exec_id` | `GetExecution` | `api.GetExecution()` ✅ |
| `POST /robots/:id/trigger` | `TriggerRobot` | `api.Trigger()` ✅ (needs conversation_id support) |
| `POST /robots/:id/intervene` | `InterveneRobot` | `api.Intervene()` ✅ (needs conversation_id support) |
| `POST /robots/:id/executions/:exec_id/pause` | `PauseExecution` | `api.PauseExecution()` ✅ |
| `POST /robots/:id/executions/:exec_id/resume` | `ResumeExecution` | `api.ResumeExecution()` ✅ |
| `POST /robots/:id/executions/:exec_id/cancel` | `CancelExecution` | `api.StopExecution()` ✅ |
| `POST /robots/:id/executions/:exec_id/retry` | `RetryExecution` | `api.RetryExecution()` ⬜ |
#### Results (2 endpoints)
| Endpoint | Handler | Backend API |
|----------|---------|-------------|
| `GET /robots/:id/results` | `ListResults` | `api.ListResults()` ⬜ |
| `GET /robots/:id/results/:result_id` | `GetResult` | `api.GetResult()` ⬜ |
#### Activities (1 endpoint)
| Endpoint | Handler | Backend API |
|----------|---------|-------------|
| `GET /robots/activities` | `ListActivities` | `api.ListActivities()` ⬜ |
#### SSE Streams (3 endpoints)
| Endpoint | Handler | Backend Event Bus |
|----------|---------|-------------------|
| `GET /robots/stream` | `StreamRobots` | ⬜ New event bus needed |
| `GET /robots/:id/executions/:exec_id/stream` | `StreamExecution` | ⬜ New event bus needed |
| `POST /robots/:id/trigger` (SSE) | `TriggerRobot` | Wrap existing `api.Trigger()` |
| `POST /robots/:id/intervene` (SSE) | `InterveneRobot` | Wrap existing `api.Intervene()` |
---
## 5. SSE Infrastructure Gaps
### 5.1 Event Bus Needed
The backend needs an event bus to publish real-time events. Currently, the robot module doesn't have one.
**Required Components:**
```go
// robot/events/bus.go (NEW PACKAGE)
type EventBus struct {
subscribers map[string][]chan Event
mu sync.RWMutex
}
type Event struct {
Type string `json:"type"` // robot_status, execution_start, etc.
Payload interface{} `json:"payload"`
}
func (bus *EventBus) Publish(event Event)
func (bus *EventBus) Subscribe(topic string) <-chan Event
func (bus *EventBus) Unsubscribe(topic string, ch <-chan Event)
```
### 5.2 Event Publishers Needed
| Event | Source | When |
|-------|--------|------|
| `robot_status` | Manager | Robot status changes |
| `execution_start` | Executor | Execution begins |
| `execution_complete` | Executor | Execution ends |
| `phase` | Executor | Phase changes |
| `task_start` | Runner | Task begins |
| `task_complete` | Runner | Task ends |
| `activity` | Multiple | Any activity event |
### 5.3 Integration Points
**In `manager/manager.go`:**
```go
// Publish when robot status changes
eventBus.Publish(Event{Type: "robot_status", Payload: ...})
```
**In `executor/standard/executor.go`:**
```go
// Publish when execution starts/ends
eventBus.Publish(Event{Type: "execution_start", Payload: ...})
```
---
## 6. i18n Support Gaps
### 6.1 Current State
- No locale parameter in backend API
- No localization infrastructure
### 6.2 Required Changes
**Add locale to context:**
```go
// types/context.go
type Context struct {
context.Context
Auth *types.AuthorizedInfo
MemberID string
Locale string // NEW: "zh-CN" | "en-US"
}
```
**Add locale helper:**
```go
// utils/locale.go (NEW)
func GetLocale(r *http.Request) string
func Localize(key, locale string) string
```
**Localized fields:**
- `RobotState.display_name`
- `RobotState.description`
- `Execution.name`
- `Execution.current_task_name`
- `ResultFile.name`
- `ResultFile.execution_name`
- `Activity.robot_name`
- `Activity.title`
- `Activity.description`
---
## 7. Data Source Gaps
### 7.1 Results Data
Results are derived from execution delivery data. Need to:
1. **Query from `store/execution.go`** - executions with delivery attachments
2. **Extract attachment metadata** - file ID, name, type, size
**Implementation:**
```go
// store/results.go (NEW)
func (s *ExecutionStore) ListResults(ctx context.Context, memberID string, opts *ResultsQuery) ([]*ResultFile, int, error) {
// Query executions with delivery.content.attachments
// Extract and format as ResultFile
}
```
### 7.2 Activities Data
Activities can be derived from:
1. **Job system logs** - existing `job.ListLogs()`
2. **Execution state changes** - from `store/execution.go`
**Implementation Options:**
**Option A: Derive from execution history**
```go
func ListActivities(ctx context.Context, query *ActivityQuery) ([]*Activity, error) {
// Query recent executions
// Map to Activity based on status changes
}
```
**Option B: Separate activity log (recommended for real-time)**
```go
// New table: __yao.robot_activity
type ActivityRecord struct {
ID int64
Type ActivityType
MemberID string
ExecutionID string
Data JSON
Timestamp time.Time
}
```
---
## 8. Implementation Priority
> **Strategy:** Low-risk phases first. Medium-risk features (Chat API, SSE) can be deferred.
> Frontend can use polling and single-submit mode as fallback.
---
### 🟢 Phase 1: Core CRUD [Low Risk]
1. ⬜ Add `Bio` field to `Robot` struct (`types/robot.go`)
2. ⬜ Add `bio` to `memberFields` in `cache/load.go`
3. ⬜ Implement `api.Create()`, `api.Update()`, `api.Remove()`
4. ⬜ Create OpenAPI handlers: list, detail, create, update, delete
5. ⬜ Add response type mapping (`name``member_id`, `description``bio`)
### 🟢 Phase 2: Execution Management [Low Risk]
1. ⬜ Add derived fields in OpenAPI layer (`name`, `current_task_name`)
2. ⬜ Implement `api.RetryExecution()`
3. ⬜ Create OpenAPI handlers: execution list, detail, control
4. ⬜ Wrap trigger/intervene (single-submit mode, no chat)
### 🟢 Phase 3: Results & Activities [Low Risk]
1. ⬜ Create `ActivityResponse` and `ResultFileResponse` types in OpenAPI layer
2. ⬜ Implement `api.ListResults()`, `api.GetResult()` (derive from executions)
3. ⬜ Implement `api.ListActivities()` (derive from execution history)
4. ⬜ Create OpenAPI handlers
### 🟢 Phase 4: i18n [Low Risk]
1. ⬜ Add `Locale` to context
2. ⬜ Add locale helper functions
3. ⬜ Implement localized response fields
---
### 🟡 Phase 5: Multi-turn Chat API [Medium Risk - Deferred]
> **Fallback:** Frontend uses single-submit mode (user input → immediate execution)
1. ⬜ Create `store/conversation.go` - temporary conversation storage
2. ⬜ Create `api/chat.go` - chat handler with LLM call
3. ⬜ Extend `api/trigger.go` - support `conversation_id`
4. ⬜ Create OpenAPI endpoint: `POST /robots/:id/chat` (SSE)
5. ⬜ Update `POST /robots/:id/trigger` to accept conversation reference
6. ⬜ Same for `POST /robots/:id/intervene`
### 🟡 Phase 6: Real-time SSE [Medium Risk - Deferred]
> **Fallback:** Frontend uses polling (GET /executions every 3-5s)
1. ⬜ Create event bus package
2. ⬜ Integrate event publishing in manager/executor
3. ⬜ Implement SSE stream handlers
4. ⬜ End-to-end testing
---
## 9. Testing Strategy
### Unit Tests
- `types/activity_test.go` - new types
- `types/result_test.go` - new types
- `api/robot_test.go` - CRUD functions
- `api/results_test.go` - results API
- `api/activities_test.go` - activities API
### Integration Tests
- `openapi/agent/robot/*_test.go` - HTTP endpoint tests
- `openapi/agent/robot/sse_test.go` - SSE stream tests
### E2E Tests
- Full flow: create robot → trigger → stream events → get results
---
## 10. Files to Modify Summary
### Backend (`yao/agent/robot/`)
#### Store Layer (Core CRUD - implement first)
| File | Action | Changes |
|------|--------|---------|
| `store/robot.go` | **Create** | `RobotStore` - Robot member CRUD (Save, Get, List, Delete, UpdateConfig) |
| `store/execution.go` | Modify | Add `ListResults()`, `GetResult()`, `ListActivities()` |
| `store/conversation.go` | Create | Temporary conversation storage (Phase 5 - Deferred) |
#### Types Layer
| File | Action | Changes |
|------|--------|---------|
| `types/robot.go` | Modify | Add `Bio` field |
| `types/conversation.go` | Create | `Conversation`, `ChatRequest`, `ChatResponse` types (Phase 5) |
| `types/context.go` | Modify | Add `Locale` field |
#### Cache Layer
| File | Action | Changes |
|------|--------|---------|
| `cache/load.go` | Modify | Add `bio` to `memberFields` slice |
#### API Layer (Thin wrappers calling store)
| File | Action | Changes |
|------|--------|---------|
| `api/robot.go` | Modify | Add `Create()`, `Update()`, `Remove()` - call store.RobotStore |
| `api/results.go` | Create | `ListResults()`, `GetResult()` - call store.ExecutionStore |
| `api/activities.go` | Create | `ListActivities()` - call store.ExecutionStore |
| `api/execution.go` | Modify | Add `RetryExecution()` |
| `api/chat.go` | Create | `Chat()` - multi-turn conversation (Phase 5 - Deferred) |
| `api/trigger.go` | Modify | Add `ConversationID` support (Phase 5 - Deferred) |
#### Events Layer (Phase 6 - Deferred)
| File | Action | Changes |
|------|--------|---------|
| `events/bus.go` | Create | Event bus for SSE |
### OpenAPI (`yao/openapi/agent/robot/`)
| File | Action | Description |
|------|--------|-------------|
| `robot.go` | Create | Route registration |
| `types.go` | Create | Request/Response types |
| `list.go` | Create | List robots handler |
| `detail.go` | Create | Robot CRUD handlers |
| `chat.go` | Create | Multi-turn chat SSE handler |
| `execution.go` | Create | Execution handlers |
| `trigger.go` | Create | Trigger/Intervene SSE (with conversation support) |
| `results.go` | Create | Results handlers |
| `activities.go` | Create | Activities handler |
| `stream.go` | Create | SSE streams |
| `filter.go` | Create | Query parsing |
| `utils.go` | Create | Utilities |
### Parent (`yao/openapi/agent/`)
| File | Action | Changes |
|------|--------|---------|
| `agent.go` | Modify | Add `robot.Attach(group.Group("/robots"), oauth)` |
---
## 11. References
- Frontend API Requirements: `cui/packages/cui/pages/mission-control/API.md`
- Backend Robot Types: `yao/agent/robot/types/`
- Backend Robot API: `yao/agent/robot/api/`
- OpenAPI Design: `yao/openapi/agent/robot/DESIGN.md`
- OpenAPI TODO: `yao/openapi/agent/robot/TODO.md`

816
openapi/agent/robot/TODO.md Normal file
View file

@ -0,0 +1,816 @@
# Robot OpenAPI - Implementation TODO
> Based on: `openapi/agent/robot/DESIGN.md`, `openapi/agent/robot/GAPS.md`
> Depends on: `yao/agent/robot/api/` (Go API layer)
> Base Path: `/v1/agent/robots`
---
## Implementation Strategy
> **Integrate frontend immediately after each phase to validate deliverables.**
> Frontend has fallback mechanisms (polling, single-submit mode).
```
🟢 Phase 1: Core CRUD ✅
Backend → SDK → Page Integration
└─ List, Get, Create, Update, Delete robots
✅ Phase 1-FE: Frontend Integration ✅ [Completed]
└─ SDK (openapi/robot.ts) ✅
└─ Page Integration (Robot list, detail, create, edit, delete) ✅
└─ UI/UX (CreatureLoading, bubble animations) ✅
✅ Phase 1.5: Robot Manager Lifecycle ✅ [Completed]
└─ Auto-start Manager on Yao startup (async)
└─ Auto-reload cache on robot update
└─ Auto-remove from cache on robot delete
└─ Graceful shutdown on Yao unload
└─ Lazy-load for non-autonomous robots (load on trigger, unload after execution)
└─ Unit tests: TestManagerLazyLoadNonAutonomous (6 test cases)
🟢 Phase 2: Execution Management
Backend → SDK → Page Integration
└─ List, Get, Control executions, Trigger/Intervene
🟢 Phase 3: Results & Activities
Backend → SDK → Page Integration
└─ List deliverables, Activity feed
🟢 Phase 4: i18n
Backend → SDK → Page Integration
└─ Locale parameter support
🟡 Medium Risk (Deferred):
Phase 5: Multi-turn Chat API
Phase 6: Real-time SSE Streams
```
---
## 🟢 Phase 1: Core CRUD ✅ [Low Risk]
**Goal:** Basic robot management endpoints
**Risk:** 🟢 Low - All new code, no changes to existing logic
**Status:** ✅ Backend Complete → Proceed to Phase 1.5 Frontend Integration
### 1.1 Backend Prerequisites ✅
#### Types & Cache
- [x] Add `Bio` field to `types.Robot` struct in `yao/agent/robot/types/robot.go`
- [x] Add `bio` to `memberFields` in `yao/agent/robot/cache/load.go`
#### Store Layer (Core CRUD - implement first)
- [x] Create `store/robot.go` with `RobotStore` struct
- [x] Implement `RobotStore.Save()` - create/update robot member
- [x] Implement `RobotStore.Get()` - get by member_id
- [x] Implement `RobotStore.List()` - list with filters
- [x] Implement `RobotStore.Delete()` - delete robot member
- [x] Implement `RobotStore.UpdateConfig()` - update config only
- [x] Implement `RobotStore.UpdateStatus()` - update status only
- [x] Add Yao permission fields support (`__yao_created_by`, `__yao_team_id`, etc.)
- [x] Add tests: `store/robot_test.go`
#### API Layer (Thin wrappers calling store)
- [x] Implement `api.CreateRobot()` - call `store.RobotStore.Save()` + cache refresh
- [x] Auto-generate `member_id` if not provided (12-digit numeric, matches existing pattern)
- [x] Implement `api.UpdateRobot()` - partial update + cache refresh
- [x] Implement `api.RemoveRobot()` - call `store.RobotStore.Delete()` + cache invalidate
- [x] Implement `api.GetRobotResponse()` - get robot as API response
- [x] Add `AuthScope` for Yao permission fields
- [x] Add request/response types in `api/types.go`
- [x] Add tests: `api/robot_test.go`
#### Utils Layer
- [x] Create `utils/convert.go` with unified type conversion functions
- [x] Implement `To<Type>` functions (ToBool, ToInt, ToFloat64, ToTimestamp, ToJSONValue)
- [x] Implement `Get<Type>` functions for map value extraction
- [x] Add tests: `utils/convert_test.go`
### 1.2 OpenAPI Setup ✅
- [x] Create `openapi/agent/robot/` directory (sub-package under agent)
- [x] Create `robot.go` - route registration with `Attach()` function
- [x] Register routes in `openapi/agent/agent.go` via `robot.Attach(group.Group("/robots"), oauth)`
- [x] Add OAuth guard middleware
### 1.3 OpenAPI Types ✅
> Note: Core types already exist in `agent/robot/api/types.go`. OpenAPI layer needs HTTP-specific types.
- [x] `types.go` - HTTP request/response types
- [x] `RobotResponse` struct (with field mapping: `name``member_id`, `description``bio`)
- [x] `RobotStatusResponse` struct
- [x] `ListRobotsResponse` struct
- [x] `CreateRobotRequest` struct (HTTP binding)
- [x] `UpdateRobotRequest` struct (HTTP binding)
- [x] `NewRobotResponse()` - conversion from `api.RobotResponse`
- [x] `NewRobotStatusResponse()` - conversion from `api.RobotState`
### 1.4 List Robots ✅
- [x] `list.go` - GET /v1/agent/robots
- [x] Parse query params: `status`, `keywords`, `page`, `pagesize`, `team_id`
- [x] Call `robot/api.ListRobots()`
- [x] Team constraint from auth info
- [x] Test: `tests/agent/robot_test.go#TestListRobots`
### 1.5 Get Robot ✅
- [x] `detail.go` - GET /v1/agent/robots/:id
- [x] Parse path param
- [x] Call `robot/api.GetRobotResponse()`
- [x] Team access check
- [x] Test: `tests/agent/robot_test.go#TestGetRobot`
### 1.6 Create Robot ✅
- [x] POST /v1/agent/robots handler
- [x] Parse HTTP request to `CreateRobotRequest`
- [x] Auto-generate `member_id` if not provided (12-digit numeric, consistent with existing API)
- [x] Apply `AuthScope` with permission fields (CreatedBy, TeamID, TenantID)
- [x] Call `robot/api.CreateRobot()`
- [x] Return created robot (201 Created)
- [x] Handle duplicate (409 Conflict)
- [x] Test: `tests/agent/robot_test.go#TestCreateRobot`
### 1.7 Update Robot ✅
- [x] PUT /v1/agent/robots/:id handler
- [x] Parse HTTP request to `UpdateRobotRequest`
- [x] Team permission check
- [x] Apply `AuthScope` with UpdatedBy
- [x] Call `robot/api.UpdateRobot()`
- [x] Return updated robot
- [x] Test: `tests/agent/robot_test.go#TestUpdateRobot`
### 1.8 Delete Robot ✅
- [x] DELETE /v1/agent/robots/:id handler
- [x] Team permission check
- [x] Call `robot/api.RemoveRobot()`
- [x] Handle running executions (409 Conflict)
- [x] Return success response
- [x] Test: `tests/agent/robot_test.go#TestDeleteRobot`
### 1.9 Status Endpoint ✅
- [x] GET /v1/agent/robots/:id/status handler
- [x] Call `robot/api.GetRobotStatus()`
- [x] Return runtime status (running count, max, last/next run)
- [x] Test: `tests/agent/robot_test.go#TestGetRobotStatus`
### 1.10 Utilities ✅
- [x] `utils.go` - helper functions
- [x] `GetLocale(c *gin.Context)` - extract locale from query/header
- [x] `ParseBoolValue()` - parse bool from string
### 1.11 Permission Logic ✅
- [x] `permission.go` - permission check functions
- [x] `CanRead()` - read permission check (creator or team member)
- [x] `CanWrite()` - write permission check (creator only)
- [x] `GetEffectiveTeamID()` - get effective team_id (user_id for personal users)
- [x] `BuildListFilter()` - build list filter based on permissions
- [x] Apply permission checks in handlers:
- [x] `GetRobot` - check `CanRead()` with `YaoTeamID` and `YaoCreatedBy`
- [x] `GetRobotStatus` - check `CanRead()`
- [x] `UpdateRobot` - check `CanWrite()`
- [x] `DeleteRobot` - check `CanWrite()`
- [x] `ListRobots` - use `BuildListFilter()` for team filtering
- [x] `CreateRobot` - auto-set `__yao_team_id` to `user_id` for personal users
- [x] Add Yao permission fields to API layer:
- [x] `api/types.go` - add `YaoCreatedBy`, `YaoTeamID` to `RobotResponse` and `RobotState`
- [x] `api/robot.go` - populate permission fields in `recordToResponse()` and `GetRobotStatus()`
- [x] `store/robot.go` - add `__yao_*` fields to `robotFields`
- [x] Permission tests in `tests/agent/robot_test.go#TestRobotPermissions`
---
## ✅ Phase 1-FE: Frontend Integration ✅ [Completed]
**Goal:** Implement frontend SDK and integrate pages to validate Phase 1 deliverables
**Status:** ✅ Completed
### 1-FE.1 SDK Implementation ✅
> Location: `cui/packages/cui/openapi/agent/robot/`
- [x] Create `robot/types.ts` - TypeScript types for Robot API
- [x] `RobotFilter` - filter options for listing (including `autonomous_mode`)
- [x] `Robot` - robot data structure
- [x] `RobotStatusResponse` - runtime status
- [x] `RobotCreateRequest` / `RobotUpdateRequest` - CRUD requests
- [x] `RobotDeleteResponse` - delete response
- [x] Create `robot/robots.ts` - Robot API SDK class (`AgentRobots`)
- [x] `List(filter)` - GET /v1/agent/robots
- [x] `Get(id)` - GET /v1/agent/robots/:id
- [x] `GetStatus(id)` - GET /v1/agent/robots/:id/status
- [x] `Create(data)` - POST /v1/agent/robots
- [x] `Update(id, data)` - PUT /v1/agent/robots/:id
- [x] `Delete(id)` - DELETE /v1/agent/robots/:id
- [x] Create `robot/index.ts` - exports
- [x] Update `agent/api.ts` - add `robots` property to Agent class
- [x] Update `agent/index.ts` - export robot module
- [x] Linter check passed
### 1-FE.2 Page Integration ✅
> Location: `cui/packages/cui/pages/mission-control/`
- [x] Create `useRobots` hook for API calls
- [x] `listRobots(filter)` - list robots with pagination
- [x] `getRobot(id)` - get single robot
- [x] `getRobotStatus(id)` - get runtime status
- [x] `createRobot(data)` - create robot
- [x] `updateRobot(id, data)` - update robot
- [x] `deleteRobot(id)` - delete robot
- [x] Error handling and loading state
- [x] Robot List Page (`mission-control/index.tsx`)
- [x] Replace mock data with `listRobots()` API (fallback to mock)
- [x] Fetch status for each robot via `getRobotStatus()`
- [x] Refresh list after robot created/updated/deleted
- [x] Empty state with "Create Agent" button (with bubble animation)
- [ ] Implement pagination (TODO: Phase 2)
- [ ] Implement filters (status, keywords, team) (TODO: Phase 2)
- [x] Robot Detail Modal (`AgentModal`)
- [x] Real-time status refresh via `getRobotStatus(id)`
- [x] Auto-refresh every 10 seconds while modal open
- [x] Merge real-time status with robot data
- [x] Create Robot (`AddAgentModal`)
- [x] Call `createRobot()` API
- [x] Handle success/error messages
- [x] Form validation (existing)
- [x] Load email domains, managers, agents, MCP servers from API
- [x] Edit Robot (`ConfigTab` in `AgentModal`)
- [x] Load robot data from API (`getRobot()`)
- [x] Load email domains, managers, roles from Team API
- [x] Load agents and MCP servers from API
- [x] Pre-populate form with existing data
- [x] Call `updateRobot()` API with `robot_config.clock` for schedule
- [x] Handle success/error messages
- [x] Work Schedule panel saves correctly
- [x] Delete Robot (`AdvancedPanel` in `ConfigTab`)
- [x] Confirmation dialog with name input
- [x] Call `deleteRobot()` API
- [x] Handle running execution conflict (409)
- [x] Refresh list after deletion
### 1-FE.3 UI/UX Enhancements ✅
- [x] `CreatureLoading` component with organic animations
- [x] Breathing aura, floating creature, orbit ring, particles
- [x] Three sizes: small, medium, large
- [x] Used in ConfigTab, ResultsTab, HistoryTab
- [x] Empty state "Create Agent" button with bubble animation
- [x] Cyan, purple, pink glowing bubbles rising
- [x] CSS variable compliance (`--color_mission_button_text`)
- [x] Consistent loading animations across all tabs
### 1-FE.4 Verification ✅
- [x] Manual test: Create → List → Get → Update → Delete
- [ ] E2E automated test (TODO: Phase 3)
- [x] Permission test: Personal user vs Team user (manual tested)
- [x] Error handling: 400, 403, 404, 409, 500
---
## 🟢 Phase 2: Execution Management ⬜ [Low Risk]
**Goal:** Execution listing, details, control, and trigger/intervene (single-submit mode)
**Risk:** 🟢 Low - Wraps existing API functions
### 2.1 List Executions ⬜
- [ ] `execution.go` - GET /v1/robots/:id/executions
- [ ] Parse query params: `status`, `trigger_type`, `keyword`, `page`, `pagesize`
- [ ] Call `robot/api.GetExecutions()`
- [ ] Add derived fields: `name`, `current_task_name`
- [ ] Format response
- [ ] Test: `tests/robot/execution_list_test.go`
### 2.2 Get Execution ⬜
- [ ] GET /v1/robots/:id/executions/:exec_id
- [ ] Call `robot/api.GetExecution()`
- [ ] Full task details with localization
- [ ] Test: `tests/robot/execution_get_test.go`
### 2.3 Execution Control ⬜
- [ ] POST /v1/robots/:id/executions/:exec_id/pause
- [ ] Call `robot/api.Pause()`
- [ ] POST /v1/robots/:id/executions/:exec_id/resume
- [ ] Call `robot/api.Resume()`
- [ ] POST /v1/robots/:id/executions/:exec_id/cancel
- [ ] Call `robot/api.Stop()`
- [ ] POST /v1/robots/:id/executions/:exec_id/retry
- [ ] Re-trigger with same input
- [ ] Test: `tests/robot/execution_control_test.go`
### 2.4 Execution Types ⬜
- [ ] Add to `types.go`:
- [ ] `ExecutionResponse` struct
- [ ] `TaskResponse` struct
- [ ] `CurrentStateResponse` struct
- [ ] `GoalsResponse` struct
- [ ] `DeliveryResultResponse` struct
### 2.5 Trigger & Intervene (Single-Submit Mode) ⬜
> **Note:** This is single-submit mode. Multi-turn chat is deferred to Phase 5.
- [ ] `trigger.go` - POST /v1/robots/:id/trigger
- [ ] Parse `TriggerRequest` (messages, attachments)
- [ ] Call `robot/api.Trigger()`
- [ ] Return execution ID and status
- [ ] Optional: Return SSE stream for progress
- [ ] Test: `tests/robot/trigger_test.go`
- [ ] POST /v1/robots/:id/intervene
- [ ] Parse `InterveneRequest`
- [ ] Call `robot/api.Intervene()`
- [ ] Return result
- [ ] Test: `tests/robot/intervene_test.go`
### 2.6 Trigger Types ⬜
- [ ] Add to `types.go`:
- [ ] `TriggerRequest` struct
- [ ] `TriggerResponse` struct
- [ ] `InterveneRequest` struct
- [ ] `InterveneResponse` struct
- [ ] `Message` struct
- [ ] `Attachment` struct
### 2.7 Frontend Integration ⬜
> Integrate immediately after backend completion
- [ ] SDK: Add execution methods to `robot.ts`
- [ ] `listExecutions(robotId, params)`
- [ ] `getExecution(robotId, execId)`
- [ ] `pauseExecution()`, `resumeExecution()`, `cancelExecution()`
- [ ] `triggerRobot(robotId, data)`
- [ ] `intervene(robotId, data)`
- [ ] Page: Execution list/detail page integration
- [ ] Page: Assign Task (trigger execution) integration
- [ ] Verify: E2E testing
---
## 🟢 Phase 3: Results & Activities ⬜ [Low Risk]
**Goal:** Deliverables listing and activity feed
**Risk:** 🟢 Low - Read-only queries, derived from existing data
### 3.1 Backend Prerequisites ⬜
#### Store Layer (Core implementation)
- [ ] Add `ExecutionStore.ListResults()` - query deliverables from execution delivery data
- [ ] Add `ExecutionStore.GetResult()` - get single deliverable detail
- [ ] Add `ExecutionStore.ListActivities()` - derive activities from execution history
#### API Layer (Thin wrappers)
- [ ] Create `api/results.go` with `ListResults()`, `GetResult()` - call store
- [ ] Create `api/activities.go` with `ListActivities()` - call store
### 3.2 Results Endpoints ⬜
- [ ] `results.go` - results handlers
- [ ] GET /v1/robots/:id/results
- [ ] Parse filters: `trigger_type`, `keyword`, `page`, `pagesize`
- [ ] Call `robot/api.ListResults()`
- [ ] Format response
- [ ] GET /v1/robots/:id/results/:result_id
- [ ] Call `robot/api.GetResult()`
- [ ] Return full delivery content
- [ ] Test: `tests/robot/results_test.go`
### 3.3 Results Types ⬜
- [ ] Add to `types.go`:
- [ ] `ResultResponse` struct
- [ ] `ResultDetailResponse` struct
- [ ] `DeliveryContentResponse` struct
- [ ] `DeliveryAttachmentResponse` struct
### 3.4 Activities Endpoints ⬜
- [ ] `activities.go` - activities handlers
- [ ] GET /v1/robots/activities
- [ ] Parse: `limit`, `since`
- [ ] Call `robot/api.ListActivities()`
- [ ] Format response
- [ ] Test: `tests/robot/activities_test.go`
### 3.5 Activity Types ⬜
- [ ] Add to `types.go`:
- [ ] `ActivityResponse` struct
- [ ] `ActivityType` constants
### 3.6 Frontend Integration ⬜
> Integrate immediately after backend completion
- [ ] SDK: Add results/activities methods to `robot.ts`
- [ ] `listResults(robotId, params)`
- [ ] `getResult(robotId, resultId)`
- [ ] `listActivities(params)`
- [ ] Page: Results Tab integration
- [ ] Page: Activity Feed integration
- [ ] Verify: E2E testing
---
## 🟢 Phase 4: i18n ⬜ [Low Risk]
**Goal:** Locale parameter support
**Risk:** 🟢 Low - Additive, optional parameter
### 4.1 Locale Handling ⬜
- [ ] Add `getLocale(r *http.Request)` to utils.go
- [ ] Parse locale from query param, body, or header
- [ ] Add `Locale` field to context if needed
### 4.2 Localized Responses ⬜
- [ ] Localize `display_name` in RobotResponse
- [ ] Localize `description` in RobotResponse
- [ ] Localize `name` in ExecutionResponse (derive from goals/input)
- [ ] Localize `current_task_name` in ExecutionResponse
### 4.3 Frontend Integration ⬜
> Integrate immediately after backend completion
- [ ] SDK: Add `locale` parameter support to all API calls
- [ ] Page: Use current language setting when calling APIs
- [ ] Verify: Data correctly localized after language switch
---
## 🟡 Phase 5: Multi-turn Chat API ⬜ [Medium Risk - Deferred]
> **Frontend Fallback:** Single-submit mode (user input → immediate execution)
> **Risk:** 🟡 Medium - New stateful component
**Goal:** Multi-turn conversation before execution
### 5.1 Backend Prerequisites ⬜
- [ ] Create `store/conversation.go` - temporary conversation storage (redis/memory)
- [ ] Create `types/conversation.go` - Conversation, ChatRequest, ChatResponse types
- [ ] Create `api/chat.go` - Chat() handler with LLM call
- [ ] Extend `api/trigger.go` - support `conversation_id` parameter
### 5.2 Chat Endpoint ⬜
- [ ] POST /v1/robots/:id/chat (SSE)
- [ ] Parse ChatRequest (conversation_id, messages, attachments)
- [ ] Create or continue conversation
- [ ] Call LLM for response
- [ ] Store updated conversation
- [ ] Return assistant message + conversation_id
- [ ] Test: `tests/robot/chat_test.go`
### 5.3 Trigger with Conversation ⬜
- [ ] Extend POST /v1/robots/:id/trigger
- [ ] Accept `conversation_id` parameter
- [ ] Use conversation history as execution input
- [ ] Auto-cleanup conversation after execution starts
---
## 🟡 Phase 6: Real-time SSE Streams ⬜ [Medium Risk - Deferred]
> **Frontend Fallback:** Polling (GET /executions every 3-5 seconds)
> **Risk:** 🟡 Medium - Requires modification of executor/manager
**Goal:** SSE streams for real-time status updates
### 6.1 Backend Event System ⬜
Need to add in `robot/`:
- [ ] Create `events/bus.go` - Event bus for pub/sub
- [ ] Integrate event publishing in `manager/manager.go`
- [ ] Integrate event publishing in `executor/standard/executor.go`
- [ ] Publish: robot_status, execution_start, execution_complete, phase, task events
### 6.2 Robot Status Stream ⬜
- [ ] `stream.go` - stream handlers
- [ ] GET /v1/robots/stream
- [ ] Subscribe to manager status updates
- [ ] Stream `robot_status` events
- [ ] Stream `execution_start` events
- [ ] Stream `execution_complete` events
- [ ] Stream `activity` events
- [ ] Test: `tests/robot/stream_test.go`
### 6.3 Execution Progress Stream ⬜
- [ ] GET /v1/robots/:id/executions/:exec_id/stream
- [ ] Subscribe to execution updates
- [ ] Stream `phase` events
- [ ] Stream `task_start` / `task_complete` events
- [ ] Stream `message` events
- [ ] Stream `delivery` event
- [ ] Stream `complete` / `error` events
- [ ] Test: `tests/robot/execution_stream_test.go`
---
## Backend Extensions Required
> **Architecture:** Store layer handles CRUD, API layer handles business logic.
> This enables reuse across Golang API, JSAPI, and Yao Process.
### robot/store/ Extensions (Core CRUD)
| Function | Phase | Risk | Status | Description |
|----------|-------|------|--------|-------------|
| `RobotStore.Save()` | 1 | 🟢 Low | ✅ | Create/update robot member |
| `RobotStore.Get()` | 1 | 🟢 Low | ✅ | Get robot by member_id |
| `RobotStore.List()` | 1 | 🟢 Low | ✅ | List robots with filters |
| `RobotStore.Delete()` | 1 | 🟢 Low | ✅ | Delete robot member |
| `RobotStore.UpdateConfig()` | 1 | 🟢 Low | ✅ | Update config only |
| `RobotStore.UpdateStatus()` | 1 | 🟢 Low | ✅ | Update status only |
| `ExecutionStore.ListResults()` | 3 | 🟢 Low | ⬜ | Query deliverables from executions |
| `ExecutionStore.GetResult()` | 3 | 🟢 Low | ⬜ | Get single deliverable |
| `ExecutionStore.ListActivities()` | 3 | 🟢 Low | ⬜ | Derive activities from history |
| Conversation store | 5 | 🟡 Medium | ⬜ | Temporary chat history (Deferred) |
### robot/types/ Extensions
| Type/Field | Phase | Risk | Status | Description |
|------------|-------|------|--------|-------------|
| `Robot.Bio` | 1 | 🟢 Low | ✅ | Add field, maps to `__yao.member.bio` |
| Execution name derivation | 2 | 🟢 Low | ⬜ | Derive in OpenAPI layer from goals or input |
> **Note:** `Robot.Name` is NOT needed. Frontend `name` maps to existing `Robot.MemberID`.
### robot/cache/ Extensions
| File | Phase | Risk | Status | Description |
|------|-------|------|--------|-------------|
| `load.go` | 1 | 🟢 Low | ✅ | Add `bio` to `memberFields` slice |
### robot/utils/ Extensions
| File | Phase | Risk | Status | Description |
|------|-------|------|--------|-------------|
| `convert.go` | 1 | 🟢 Low | ✅ | Unified type conversion utilities |
| `convert_test.go` | 1 | 🟢 Low | ✅ | Tests for conversion utilities |
### robot/api/ Extensions (Thin wrappers calling store)
| Function | Phase | Risk | Status | Description |
|----------|-------|------|--------|-------------|
| `CreateRobot()` | 1 | 🟢 Low | ✅ | Call `store.RobotStore.Save()` + cache refresh |
| `UpdateRobot()` | 1 | 🟢 Low | ✅ | Partial update + cache refresh |
| `RemoveRobot()` | 1 | 🟢 Low | ✅ | Call `store.RobotStore.Delete()` + cache invalidate |
| `GetRobotResponse()` | 1 | 🟢 Low | ✅ | Get robot as API response |
| `ListResults()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.ListResults()` |
| `GetResult()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.GetResult()` |
| `ListActivities()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.ListActivities()` |
| `RetryExecution()` | 2 | 🟢 Low | ⬜ | Re-trigger with same input |
| `Chat()` | 5 | 🟡 Medium | ⬜ | Multi-turn conversation (Deferred) |
### Event System (Phase 6 - Deferred)
| Component | Phase | Risk | Description |
|-----------|-------|------|-------------|
| Event bus | 6 | 🟡 Medium | Pub/sub for real-time updates |
| Manager events | 6 | 🟡 Medium | Publish robot status changes |
| Executor events | 6 | 🟡 Medium | Publish execution progress |
---
## Testing Strategy
### Test Files Structure
```
yao/openapi/tests/robot/
├── list_test.go
├── get_test.go
├── create_test.go
├── update_test.go
├── delete_test.go
├── execution_list_test.go
├── execution_get_test.go
├── execution_control_test.go
├── trigger_test.go
├── intervene_test.go
├── results_test.go
├── activities_test.go
├── stream_test.go
└── execution_stream_test.go
```
### Test Utilities
- [ ] Create test robot helper
- [ ] Create test execution helper
- [ ] SSE client for streaming tests
- [ ] Mock data generators
---
## Progress Tracking
| Phase | Risk | Backend | Frontend | Description |
|-------|------|---------|----------|-------------|
| 1. Core CRUD | 🟢 | ✅ | ✅ | Robot CRUD endpoints |
| 1-FE Frontend Integration | 🟢 | - | ✅ | SDK ✅, Page Integration ✅, UI/UX ✅ |
| 1.5 Manager Lifecycle | 🟢 | ✅ | - | Auto-start, auto-reload, graceful shutdown |
| 2. Execution | 🟢 | ⬜ | ⬜ | Execution listing, control, trigger |
| 3. Results/Activities | 🟢 | ⬜ | ⬜ | Deliverables and activity feed |
| 4. i18n | 🟢 | ⬜ | ⬜ | Locale parameter support |
| 5. Chat API | 🟡 | ⬜ | ⬜ | Multi-turn conversation (Deferred) |
| 6. SSE Streams | 🟡 | ⬜ | ⬜ | Real-time status updates (Deferred) |
Legend: ⬜ Not started | 🟡 In progress | ✅ Complete
### Phase 1 Detailed Status
| Component | Status | Notes |
|-----------|--------|-------|
| `types.Robot.Bio` | ✅ | Field added |
| `cache/load.go` | ✅ | `bio` in memberFields |
| `store/robot.go` | ✅ | Full CRUD with permission fields |
| `store/robot_test.go` | ✅ | Integration tests |
| `api/robot.go` | ✅ | Create/Update/Remove/GetResponse |
| `api/types.go` | ✅ | Request/Response types, AuthScope |
| `api/robot_test.go` | ✅ | API tests |
| `utils/convert.go` | ✅ | Type conversion utilities |
| `utils/convert_test.go` | ✅ | Unit tests |
| `openapi/agent/robot/robot.go` | ✅ | Route registration with Attach() |
| `openapi/agent/robot/types.go` | ✅ | HTTP request/response types |
| `openapi/agent/robot/list.go` | ✅ | List robots handler with permission filter |
| `openapi/agent/robot/detail.go` | ✅ | CRUD handlers with permission checks |
| `openapi/agent/robot/permission.go` | ✅ | Permission check functions (CanRead/CanWrite) |
| `openapi/agent/robot/utils.go` | ✅ | Helper functions |
| `openapi/agent/agent.go` | ✅ | Robot routes registered |
| `openapi/tests/agent/robot_test.go` | ✅ | Integration tests + Permission tests |
---
## Quick Reference
### Current Location
```
yao/openapi/agent/robot/ # This directory (sub-package under agent)
├── DESIGN.md # Design document ✅
├── TODO.md # This file ✅
├── robot.go # Route registration (Attach function) ✅
├── types.go # All request/response types ✅
├── list.go # GET /v1/agent/robots ✅
├── detail.go # GET/POST/PUT/DELETE /v1/agent/robots/:id ✅
├── permission.go # Permission check functions (CanRead/CanWrite) ✅
├── utils.go # Utilities ✅
├── execution.go # Execution endpoints (Phase 2)
├── trigger.go # Trigger/Intervene SSE (Phase 2)
├── results.go # Results endpoints (Phase 3)
├── activities.go # Activities endpoint (Phase 3)
├── stream.go # Real-time streams (Phase 6 - Deferred)
└── filter.go # Query filtering (optional)
```
### Parent Directory
```
yao/openapi/agent/
├── agent.go # MODIFY: add robot.Attach() call
├── assistant.go # Existing
├── filter.go # Existing
├── models.go # Existing
├── types.go # Existing
└── robot/ # NEW sub-package (this directory)
└── ...
```
### Route Registration (in agent/agent.go)
```go
import "github.com/yaoapp/yao/openapi/agent/robot"
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.Use(oauth.Guard)
// Existing assistant routes
group.GET("/assistants", ListAssistants)
group.POST("/assistants", CreateAssistant)
group.GET("/assistants/tags", ListAssistantTags)
group.GET("/assistants/:id", GetAssistant)
group.GET("/assistants/:id/info", GetAssistantInfo)
group.PUT("/assistants/:id", UpdateAssistant)
// Robot routes (NEW)
robot.Attach(group.Group("/robots"), oauth)
}
```
### Dependencies
| Package | Usage |
|---------|-------|
| `yao/agent/robot/api` | Go API functions (Get, List, Trigger, etc.) |
| `yao/agent/robot/types` | Robot types (Robot, Execution, etc.) |
| `yao/openapi/oauth` | Authentication, Guard middleware |
| `yao/openapi/oauth/types` | OAuth types (AuthorizedInfo) |
| `yao/openapi/response` | Response helpers |
### Import Path
```go
package robot
import (
"github.com/gin-gonic/gin"
robotapi "github.com/yaoapp/yao/agent/robot/api"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/openapi/oauth/types"
)
```
---
## Notes
### Priority
| Priority | Phase | Required For | Risk |
|----------|-------|--------------|------|
| 1 | Phase 1 (CRUD) | Basic UI functionality | 🟢 Low |
| 2 | Phase 2 (Execution) | Active/History tabs, Assign Task | 🟢 Low |
| 3 | Phase 3 (Results) | Results tab | 🟢 Low |
| 4 | Phase 4 (i18n) | Multi-language support | 🟢 Low |
| 5 | Phase 5 (Chat) | Enhanced UX (deferred) | 🟡 Medium |
| 6 | Phase 6 (SSE) | Real-time updates (deferred) | 🟡 Medium |
### Frontend Fallbacks
| Feature | Full Implementation | Fallback |
|---------|---------------------|----------|
| Assign Task | Multi-turn chat → Confirm → Execute | Single-submit → Execute |
| Real-time Status | SSE push | Polling every 3-5s |
### Frontend Integration
**Execute immediately after each phase backend completion:**
1. **SDK Implementation** - `cui/packages/cui/openapi/agent/robot/`
2. **Type Definitions** - TypeScript request/response types
3. **Hook Implementation** - `cui/packages/cui/hooks/useRobots.ts`
4. **Page Integration** - Replace mock data, call real APIs
5. **E2E Verification** - Full flow testing
**File Locations:**
```
cui/packages/cui/
├── openapi/
│ └── agent/
│ └── robot/
│ ├── types.ts # TypeScript types
│ ├── robots.ts # AgentRobots SDK class
│ └── index.ts # Exports
├── hooks/
│ └── useRobots.ts # React hook for robot API calls
├── styles/
│ └── preset/
│ └── vars.less # CSS variables (--color_mission_button_text)
└── pages/
└── mission-control/
├── index.tsx # Robot list (grid) page
├── index.less # Styles with bubble animations
└── components/
├── AgentModal/ # Robot detail modal
├── AddAgentModal/ # Create robot modal
└── CreatureLoading/ # Branded loading component
├── index.tsx
└── index.less
```
### Incremental Deployment
Each phase independently deliverable:
| Phase | Backend | Frontend | Verifiable Features |
|-------|---------|----------|---------------------|
| 1 | ✅ | ✅ | Robot CRUD basic management |
| 2 | ⬜ | ⬜ | Execution list/control/trigger |
| 3 | ⬜ | ⬜ | Results/Activities viewing |
| 4 | ⬜ | ⬜ | Multi-language support |
| 5 | ⬜ | ⬜ | Multi-turn chat UX (optional) |
| 6 | ⬜ | ⬜ | Real-time push (optional) |

View file

@ -0,0 +1,432 @@
package robot
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/kun/log"
robotapi "github.com/yaoapp/yao/agent/robot/api"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/response"
)
// GetRobot retrieves a single robot by ID
// GET /v1/agent/robots/:id
func GetRobot(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID from URL parameter
robotID := c.Param("id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Get robot via API
robotResp, err := robotapi.GetRobotResponse(ctx, robotID)
if err != nil {
log.Error("Failed to get robot %s: %v", robotID, err)
// Check for not found error
if err == robottypes.ErrRobotNotFound {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check read permission
// Permission rules:
// - No constraints: allow all
// - OwnerOnly: user must be the creator
// - TeamOnly: robot must belong to user's team
if !CanRead(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to access this robot",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Convert to HTTP response
resp := NewResponse(robotResp)
response.RespondWithSuccess(c, response.StatusOK, resp)
}
// GetRobotStatus retrieves the runtime status of a robot
// GET /v1/agent/robots/:id/status
func GetRobotStatus(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID from URL parameter
robotID := c.Param("id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Get robot status via API
status, err := robotapi.GetRobotStatus(ctx, robotID)
if err != nil {
log.Error("Failed to get robot status %s: %v", robotID, err)
if err == robottypes.ErrRobotNotFound {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot status: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check read permission
if !CanRead(c, authInfo, status.YaoTeamID, status.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to access this robot",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Convert to HTTP response
resp := NewStatusResponse(status)
response.RespondWithSuccess(c, response.StatusOK, resp)
}
// CreateRobot creates a new robot
// POST /v1/agent/robots
func CreateRobot(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Parse request body
var req CreateRobotRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Validate required fields
if req.DisplayName == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "display_name is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Generate member_id if not provided (follows existing API pattern)
if req.MemberID == "" {
generatedID, err := GenerateMemberID(c.Request.Context())
if err != nil {
log.Error("Failed to generate member_id: %v", err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to generate member_id: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
req.MemberID = generatedID
}
// Determine effective team_id:
// - If user has a team selected (authInfo.TeamID), use it
// - Otherwise, for personal users, use user_id as team_id
effectiveTeamID := GetEffectiveTeamID(authInfo)
if req.TeamID == "" {
req.TeamID = effectiveTeamID
}
// Apply team constraint from auth if TeamOnly
if authInfo != nil && authInfo.Constraints.TeamOnly && authInfo.TeamID != "" {
// Force team_id to auth team_id
req.TeamID = authInfo.TeamID
}
// Still require team_id after all fallbacks
if req.TeamID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "team_id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Convert to API request
apiReq := req.ToAPICreateRequest()
// Apply Yao permission fields
// Key rule: __yao_team_id = authInfo.TeamID if has team, otherwise = authInfo.UserID
if authInfo != nil {
yaoTeamID := authInfo.TeamID
if yaoTeamID == "" {
// For personal users (no team), use user_id as __yao_team_id
// This ensures the robot is scoped to the individual user
yaoTeamID = authInfo.UserID
}
apiReq.AuthScope = &robotapi.AuthScope{
CreatedBy: authInfo.UserID,
TeamID: yaoTeamID,
TenantID: authInfo.TenantID,
}
}
// Create robot context
ctx := &robottypes.Context{}
// Call API layer
robotResp, err := robotapi.CreateRobot(ctx, apiReq)
if err != nil {
log.Error("Failed to create robot: %v", err)
// Check for duplicate error
if strings.Contains(err.Error(), "already exists") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusConflict, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to create robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Convert to HTTP response
resp := NewResponse(robotResp)
response.RespondWithSuccess(c, response.StatusCreated, resp)
}
// UpdateRobot updates an existing robot
// PUT /v1/agent/robots/:id
func UpdateRobot(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID from URL parameter
robotID := c.Param("id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body
var req UpdateRobotRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Check permission - first get the robot to verify ownership/team
existingRobot, err := robotapi.GetRobotResponse(ctx, robotID)
if err != nil {
if err == robottypes.ErrRobotNotFound {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check write permission (only creator can update)
if !CanWrite(c, authInfo, existingRobot.YaoTeamID, existingRobot.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to update this robot",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Convert to API request
apiReq := req.ToAPIUpdateRequest()
// Apply Yao permission fields
if authInfo != nil {
apiReq.AuthScope = &robotapi.AuthScope{
UpdatedBy: authInfo.UserID,
}
}
// Call API layer
robotResp, err := robotapi.UpdateRobot(ctx, robotID, apiReq)
if err != nil {
log.Error("Failed to update robot %s: %v", robotID, err)
if err == robottypes.ErrRobotNotFound {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to update robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Convert to HTTP response
resp := NewResponse(robotResp)
response.RespondWithSuccess(c, response.StatusOK, resp)
}
// DeleteRobot deletes a robot
// DELETE /v1/agent/robots/:id
func DeleteRobot(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID from URL parameter
robotID := c.Param("id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Check permission - first get the robot to verify ownership/team
existingRobot, err := robotapi.GetRobotResponse(ctx, robotID)
if err != nil {
if err == robottypes.ErrRobotNotFound {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check write permission (only creator can delete)
if !CanWrite(c, authInfo, existingRobot.YaoTeamID, existingRobot.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to delete this robot",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Call API layer
err = robotapi.RemoveRobot(ctx, robotID)
if err != nil {
log.Error("Failed to delete robot %s: %v", robotID, err)
// Check for running executions
if strings.Contains(err.Error(), "running executions") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusConflict, errorResp)
return
}
if err == robottypes.ErrRobotNotFound {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to delete robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Return success with no content
response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{
"member_id": robotID,
"deleted": true,
})
}

117
openapi/agent/robot/list.go Normal file
View file

@ -0,0 +1,117 @@
package robot
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/kun/log"
robotapi "github.com/yaoapp/yao/agent/robot/api"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/response"
)
// ListRobots lists robots with pagination and filtering
// GET /v1/agent/robots
func ListRobots(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Parse pagination parameters
page := 1
if pageStr := c.Query("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
pageSize := 20
if pageSizeStr := c.Query("pagesize"); pageSizeStr != "" {
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 {
pageSize = ps
}
}
// Parse filter parameters
requestedTeamID := strings.TrimSpace(c.Query("team_id"))
status := strings.TrimSpace(c.Query("status"))
keywords := strings.TrimSpace(c.Query("keywords"))
autonomousModeStr := strings.TrimSpace(c.Query("autonomous_mode"))
// Apply permission-based filtering
// This ensures users only see robots they have access to:
// - No constraints: use requested team_id or no filter
// - TeamOnly: force filter to user's team
// - OwnerOnly: filter by user_id (personal resources)
effectiveTeamID := BuildListFilter(c, authInfo, requestedTeamID)
// Build query
query := &robotapi.ListQuery{
TeamID: effectiveTeamID,
Keywords: keywords,
Page: page,
PageSize: pageSize,
}
if status != "" {
query.Status = robottypes.RobotStatus(status)
}
// Parse autonomous_mode filter: "true" or "false" to filter, empty/other to show all
if autonomousModeStr == "true" {
autonomousMode := true
query.AutonomousMode = &autonomousMode
} else if autonomousModeStr == "false" {
autonomousMode := false
query.AutonomousMode = &autonomousMode
}
// Create robot context
ctx := &robottypes.Context{}
// Call API layer
result, err := robotapi.ListRobots(ctx, query)
if err != nil {
log.Error("Failed to list robots: %v", err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to list robots: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Convert to HTTP response format
robots := make([]*Response, 0, len(result.Data))
for _, r := range result.Data {
robots = append(robots, newResponseFromRobot(r))
}
resp := &ListResponse{
Data: robots,
Total: result.Total,
Page: result.Page,
PageSize: result.PageSize,
}
response.RespondWithSuccess(c, response.StatusOK, resp)
}
// newResponseFromRobot converts types.Robot to Response
func newResponseFromRobot(r *robottypes.Robot) *Response {
if r == nil {
return nil
}
return &Response{
Name: r.MemberID, // Frontend mapping: name ← member_id
Description: r.Bio, // Frontend mapping: description ← bio
MemberID: r.MemberID,
TeamID: r.TeamID,
RobotStatus: string(r.Status),
AutonomousMode: r.AutonomousMode,
DisplayName: r.DisplayName,
Bio: r.Bio,
SystemPrompt: r.SystemPrompt,
RobotEmail: r.RobotEmail,
}
}

View file

@ -0,0 +1,135 @@
package robot
import (
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// Permission check functions for robot access control
//
// Permission Rules:
// 1. No auth info or no constraints: allow all
// 2. OwnerOnly: user can only access resources they created (__yao_created_by == userID)
// 3. TeamOnly: user can access resources in their team (__yao_team_id == teamID)
// 4. For personal users (no team): __yao_team_id should be empty or equal to user_id
//
// Read vs Write:
// - Read: team members can read team resources
// - Write: only creator or team owner can write (update/delete)
// CanRead checks if the user has read permission for a robot
// Read permission is granted if:
// - No auth info (public access)
// - No constraints (admin/system)
// - User is the creator (__yao_created_by == userID)
// - TeamOnly: robot belongs to user's team (__yao_team_id == teamID)
func CanRead(c *gin.Context, authInfo *types.AuthorizedInfo, robotTeamID, robotCreatedBy string) bool {
// No auth info, allow access (handled by OAuth guard)
if authInfo == nil {
return true
}
// No constraints, allow access (admin/system user)
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
return true
}
// User is the creator - always allow
if robotCreatedBy != "" && robotCreatedBy == authInfo.UserID {
return true
}
// TeamOnly constraint: check team membership
if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) {
// Robot belongs to user's team
if robotTeamID != "" && robotTeamID == authInfo.TeamID {
return true
}
}
// OwnerOnly constraint: only creator can access (already checked above)
// If we reach here with OwnerOnly, user is not the creator
if authInfo.Constraints.OwnerOnly {
return false
}
return false
}
// CanWrite checks if the user has write permission for a robot (update/delete)
// Write permission is more restrictive:
// - No auth info: deny (should not happen, OAuth guard will block)
// - No constraints: allow (admin/system)
// - User is the creator: allow
// - TeamOnly + OwnerOnly: user must be creator AND in the same team
func CanWrite(c *gin.Context, authInfo *types.AuthorizedInfo, robotTeamID, robotCreatedBy string) bool {
// No auth info, deny write access
if authInfo == nil {
return false
}
// No constraints, allow access (admin/system user)
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
return true
}
// User is the creator - allow write
if robotCreatedBy != "" && robotCreatedBy == authInfo.UserID {
// If TeamOnly is also set, verify team membership
if authInfo.Constraints.TeamOnly {
if robotTeamID == "" || robotTeamID == authInfo.TeamID {
return true
}
return false
}
return true
}
// Not the creator - deny write access
// (In the future, we could add team admin/owner check here)
return false
}
// GetEffectiveTeamID returns the effective team_id for a robot
// For personal users (no team selected), returns user_id as team_id
// For team users, returns the selected team_id
func GetEffectiveTeamID(authInfo *types.AuthorizedInfo) string {
if authInfo == nil {
return ""
}
// If user has a team selected, use it
if authInfo.TeamID != "" {
return authInfo.TeamID
}
// For personal users, use user_id as team_id
// This ensures resources are scoped to the individual user
return authInfo.UserID
}
// BuildListFilter builds filter conditions for listing robots based on permissions
// Returns teamID filter to apply to the query
func BuildListFilter(c *gin.Context, authInfo *types.AuthorizedInfo, requestedTeamID string) string {
if authInfo == nil {
return requestedTeamID
}
// No constraints - use requested filter or no filter
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
return requestedTeamID
}
// TeamOnly constraint: force filter to user's team
if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) {
return authInfo.TeamID
}
// OwnerOnly constraint: filter by user_id as team_id (personal resources)
if authInfo.Constraints.OwnerOnly {
return authInfo.UserID
}
return requestedTeamID
}

View file

@ -0,0 +1,25 @@
package robot
import (
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// Attach attaches the robot API handlers to the router with OAuth protection
// This provides OAuth-protected endpoints for robot management
// Base path: /v1/agent/robots
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// Apply OAuth guard to all routes
group.Use(oauth.Guard)
// Robot CRUD - Standard REST endpoints
group.GET("", ListRobots) // GET /robots - List robots with pagination and filtering
group.POST("", CreateRobot) // POST /robots - Create a new robot
group.GET("/:id", GetRobot) // GET /robots/:id - Get robot details
group.PUT("/:id", UpdateRobot) // PUT /robots/:id - Update robot
group.DELETE("/:id", DeleteRobot) // DELETE /robots/:id - Delete robot
// Robot Status
group.GET("/:id/status", GetRobotStatus) // GET /robots/:id/status - Get robot runtime status
}

View file

@ -0,0 +1,255 @@
package robot
import (
"time"
robotapi "github.com/yaoapp/yao/agent/robot/api"
)
// ==================== Request Types ====================
// CreateRobotRequest - HTTP request for creating a robot
type CreateRobotRequest struct {
// Identity (member_id is optional - auto-generated if not provided)
MemberID string `json:"member_id,omitempty"` // Unique robot identifier (optional, auto-generated if empty)
TeamID string `json:"team_id,omitempty"` // Team ID (optional, defaults to auth team or user_id)
// Profile
DisplayName string `json:"display_name" binding:"required"` // Display name
Bio string `json:"bio,omitempty"` // Robot description
Avatar string `json:"avatar,omitempty"` // Avatar URL
// Identity & Role
SystemPrompt string `json:"system_prompt,omitempty"` // System prompt
RoleID string `json:"role_id,omitempty"` // Role within team
ManagerID string `json:"manager_id,omitempty"` // Direct manager user_id
// Status
Status string `json:"status,omitempty"` // Member status: active | inactive | pending | suspended
RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle | working | paused | error | maintenance
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled
// Communication
RobotEmail string `json:"robot_email,omitempty"` // Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
// Capabilities
RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
LanguageModel string `json:"language_model,omitempty"` // Language model name
// Limits
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
}
// UpdateRobotRequest - HTTP request for updating a robot
type UpdateRobotRequest struct {
// Profile
DisplayName *string `json:"display_name,omitempty"` // Display name
Bio *string `json:"bio,omitempty"` // Robot description
Avatar *string `json:"avatar,omitempty"` // Avatar URL
// Identity & Role
SystemPrompt *string `json:"system_prompt,omitempty"` // System prompt
RoleID *string `json:"role_id,omitempty"` // Role within team
ManagerID *string `json:"manager_id,omitempty"` // Direct manager user_id
// Status
Status *string `json:"status,omitempty"` // Member status
RobotStatus *string `json:"robot_status,omitempty"` // Robot status
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode
// Communication
RobotEmail *string `json:"robot_email,omitempty"` // Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules
// Capabilities
RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON
Agents interface{} `json:"agents,omitempty"` // Accessible agents
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers
LanguageModel *string `json:"language_model,omitempty"` // Language model name
// Limits
CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
}
// ==================== Response Types ====================
// Response - HTTP response for a robot
// Maps to frontend expectations: name ← member_id, description ← bio
type Response struct {
// Basic (mapped for frontend)
ID int64 `json:"id,omitempty"`
Name string `json:"name"` // Frontend name ← member_id
Description string `json:"description"` // Frontend description ← bio
// Original fields
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
Status string `json:"status"`
RobotStatus string `json:"robot_status"`
AutonomousMode bool `json:"autonomous_mode"`
// Profile
DisplayName string `json:"display_name"`
Bio string `json:"bio,omitempty"`
Avatar string `json:"avatar,omitempty"`
// Identity & Role
SystemPrompt string `json:"system_prompt,omitempty"`
RoleID string `json:"role_id,omitempty"`
ManagerID string `json:"manager_id,omitempty"`
// Communication
RobotEmail string `json:"robot_email,omitempty"`
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"`
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"`
// Capabilities
RobotConfig interface{} `json:"robot_config,omitempty"`
Agents interface{} `json:"agents,omitempty"`
MCPServers interface{} `json:"mcp_servers,omitempty"`
LanguageModel string `json:"language_model,omitempty"`
// Limits
CostLimit float64 `json:"cost_limit,omitempty"`
// Ownership & Audit
InvitedBy string `json:"invited_by,omitempty"`
JoinedAt *time.Time `json:"joined_at,omitempty"`
// Timestamps
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// StatusResponse - runtime status response
type StatusResponse struct {
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
DisplayName string `json:"display_name"`
Bio string `json:"bio,omitempty"`
Status string `json:"status"` // Robot runtime status
Running int `json:"running"` // Current running executions
MaxRunning int `json:"max_running"` // Maximum concurrent executions
LastRun *time.Time `json:"last_run,omitempty"`
NextRun *time.Time `json:"next_run,omitempty"`
RunningIDs []string `json:"running_ids,omitempty"` // IDs of running executions
}
// ListResponse - paginated list response
type ListResponse struct {
Data []*Response `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pagesize"`
}
// ==================== Conversion Functions ====================
// NewResponse creates a Response from api.RobotResponse
func NewResponse(r *robotapi.RobotResponse) *Response {
if r == nil {
return nil
}
return &Response{
ID: r.ID,
Name: r.MemberID, // Frontend mapping: name ← member_id
Description: r.Bio, // Frontend mapping: description ← bio
MemberID: r.MemberID,
TeamID: r.TeamID,
Status: r.Status,
RobotStatus: r.RobotStatus,
AutonomousMode: r.AutonomousMode,
DisplayName: r.DisplayName,
Bio: r.Bio,
Avatar: r.Avatar,
SystemPrompt: r.SystemPrompt,
RoleID: r.RoleID,
ManagerID: r.ManagerID,
RobotEmail: r.RobotEmail,
AuthorizedSenders: r.AuthorizedSenders,
EmailFilterRules: r.EmailFilterRules,
RobotConfig: r.RobotConfig,
Agents: r.Agents,
MCPServers: r.MCPServers,
LanguageModel: r.LanguageModel,
CostLimit: r.CostLimit,
InvitedBy: r.InvitedBy,
JoinedAt: r.JoinedAt,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
// ToAPICreateRequest converts HTTP request to api.CreateRobotRequest
func (r *CreateRobotRequest) ToAPICreateRequest() *robotapi.CreateRobotRequest {
return &robotapi.CreateRobotRequest{
MemberID: r.MemberID,
TeamID: r.TeamID,
DisplayName: r.DisplayName,
Bio: r.Bio,
Avatar: r.Avatar,
SystemPrompt: r.SystemPrompt,
RoleID: r.RoleID,
ManagerID: r.ManagerID,
Status: r.Status,
RobotStatus: r.RobotStatus,
AutonomousMode: r.AutonomousMode,
RobotEmail: r.RobotEmail,
AuthorizedSenders: r.AuthorizedSenders,
EmailFilterRules: r.EmailFilterRules,
RobotConfig: r.RobotConfig,
Agents: r.Agents,
MCPServers: r.MCPServers,
LanguageModel: r.LanguageModel,
CostLimit: r.CostLimit,
}
}
// ToAPIUpdateRequest converts HTTP request to api.UpdateRobotRequest
func (r *UpdateRobotRequest) ToAPIUpdateRequest() *robotapi.UpdateRobotRequest {
return &robotapi.UpdateRobotRequest{
DisplayName: r.DisplayName,
Bio: r.Bio,
Avatar: r.Avatar,
SystemPrompt: r.SystemPrompt,
RoleID: r.RoleID,
ManagerID: r.ManagerID,
Status: r.Status,
RobotStatus: r.RobotStatus,
AutonomousMode: r.AutonomousMode,
RobotEmail: r.RobotEmail,
AuthorizedSenders: r.AuthorizedSenders,
EmailFilterRules: r.EmailFilterRules,
RobotConfig: r.RobotConfig,
Agents: r.Agents,
MCPServers: r.MCPServers,
LanguageModel: r.LanguageModel,
CostLimit: r.CostLimit,
}
}
// NewStatusResponse creates a StatusResponse from api.RobotState
func NewStatusResponse(s *robotapi.RobotState) *StatusResponse {
if s == nil {
return nil
}
return &StatusResponse{
MemberID: s.MemberID,
TeamID: s.TeamID,
DisplayName: s.DisplayName,
Bio: s.Bio,
Status: string(s.Status),
Running: s.Running,
MaxRunning: s.MaxRunning,
LastRun: s.LastRun,
NextRun: s.NextRun,
RunningIDs: s.RunningIDs,
}
}

View file

@ -0,0 +1,101 @@
package robot
import (
"context"
"fmt"
"strings"
"github.com/gin-gonic/gin"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/yaoapp/gou/model"
)
// GetLocale extracts locale from request
// Priority: query param > Accept-Language header > default
func GetLocale(c *gin.Context) string {
// Check query param first
if locale := c.Query("locale"); locale != "" {
return strings.ToLower(strings.TrimSpace(locale))
}
// Check Accept-Language header
if acceptLang := c.GetHeader("Accept-Language"); acceptLang != "" {
// Parse first language from header (e.g., "en-US,en;q=0.9" -> "en-us")
parts := strings.Split(acceptLang, ",")
if len(parts) > 0 {
lang := strings.Split(parts[0], ";")[0]
return strings.ToLower(strings.TrimSpace(lang))
}
}
// Default locale
return "en-us"
}
// ParseBoolValue parses various string formats into a boolean pointer
func ParseBoolValue(value string) *bool {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "1", "true", "yes", "on":
v := true
return &v
case "0", "false", "no", "off":
v := false
return &v
}
return nil
}
// ==================== Member ID Generation ====================
// Follows the same pattern as openapi/oauth/providers/user/utils.go
const memberModel = "__yao.member"
// GenerateMemberID generates a new unique member_id for robot creation
// Uses numeric ID (12 characters) with collision detection
func GenerateMemberID(ctx context.Context) (string, error) {
const maxRetries = 10
for i := 0; i < maxRetries; i++ {
// Generate 12-digit numeric ID (matches existing pattern)
id, err := gonanoid.Generate("0123456789", 12)
if err != nil {
return "", fmt.Errorf("failed to generate member_id: %w", err)
}
// Check if ID already exists
exists, err := memberIDExists(ctx, id)
if err != nil {
return "", fmt.Errorf("failed to check member_id existence: %w", err)
}
if !exists {
return id, nil
}
// ID exists, retry
}
return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries)
}
// memberIDExists checks if a member_id already exists in the database
func memberIDExists(ctx context.Context, memberID string) (bool, error) {
m := model.Select(memberModel)
if m == nil {
return false, fmt.Errorf("model %s not found", memberModel)
}
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"},
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return false, err
}
return len(members) > 0, nil
}

View file

@ -49,7 +49,8 @@ type AssistantFilterParams struct {
Page int
PageSize int
Keywords string
Type string
Type string // Single type filter
Types []string // Multiple types filter (IN query)
Connector string
AssistantID string
AssistantIDs []string
@ -70,6 +71,7 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt
Keywords: params.Keywords,
Tags: params.Tags,
Type: params.Type,
Types: params.Types,
Connector: params.Connector,
AssistantID: params.AssistantID,
AssistantIDs: params.AssistantIDs,
@ -79,8 +81,8 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt
Automated: params.Automated,
}
// Set default type if not specified
if filter.Type == "" {
// Set default type if not specified (only when Types is also empty)
if filter.Type == "" && len(filter.Types) == 0 {
filter.Type = "assistant"
}

View file

@ -0,0 +1,944 @@
package openapi_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
// TestListRobots tests the robot listing endpoint
func TestListRobots(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Robot List Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("ListRobotsSuccess", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
// Verify pagination fields exist
assert.Contains(t, response, "data")
assert.Contains(t, response, "page")
assert.Contains(t, response, "pagesize")
assert.Contains(t, response, "total")
})
t.Run("ListRobotsWithPagination", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?page=1&pagesize=5", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, float64(1), response["page"])
assert.Equal(t, float64(5), response["pagesize"])
})
t.Run("ListRobotsWithAutonomousModeFilter", func(t *testing.T) {
// Test with autonomous_mode=true
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?autonomous_mode=true", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
// Verify response structure
assert.Contains(t, response, "data")
assert.Contains(t, response, "total")
// If there are robots, verify they are all autonomous
if data, ok := response["data"].([]interface{}); ok && len(data) > 0 {
for _, item := range data {
if robot, ok := item.(map[string]interface{}); ok {
assert.True(t, robot["autonomous_mode"].(bool), "All robots should have autonomous_mode=true")
}
}
}
})
t.Run("ListRobotsWithAutonomousModeFalse", func(t *testing.T) {
// Test with autonomous_mode=false
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?autonomous_mode=false", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
// Verify response structure
assert.Contains(t, response, "data")
assert.Contains(t, response, "total")
// If there are robots, verify they are all on-demand (not autonomous)
if data, ok := response["data"].([]interface{}); ok && len(data) > 0 {
for _, item := range data {
if robot, ok := item.(map[string]interface{}); ok {
assert.False(t, robot["autonomous_mode"].(bool), "All robots should have autonomous_mode=false")
}
}
}
})
t.Run("ListRobotsUnauthorized", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots", nil)
require.NoError(t, err)
// No Authorization header
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestCreateRobot tests the robot creation endpoint
func TestCreateRobot(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Robot Create Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Track created robots for cleanup
var createdRobotIDs []string
defer func() {
// Cleanup created robots
for _, robotID := range createdRobotIDs {
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
http.DefaultClient.Do(req)
}
}()
t.Run("CreateRobotSuccess", func(t *testing.T) {
robotID := fmt.Sprintf("test_robot_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": "Test Robot",
"bio": "A test robot for API testing",
"robot_email": "test@robot.local",
}
body, _ := json.Marshal(createData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, robotID, response["member_id"])
assert.Equal(t, "Test Robot", response["display_name"])
assert.Equal(t, "A test robot for API testing", response["bio"])
// Track for cleanup
createdRobotIDs = append(createdRobotIDs, robotID)
})
t.Run("CreateRobotMissingRequiredFields", func(t *testing.T) {
// Missing member_id
createData := map[string]interface{}{
"team_id": "test_team_001",
"display_name": "Test Robot",
}
body, _ := json.Marshal(createData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("CreateRobotDuplicate", func(t *testing.T) {
robotID := fmt.Sprintf("test_robot_dup_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": "Test Robot Duplicate",
}
body, _ := json.Marshal(createData)
// First create
req1, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
req1.Header.Set("Content-Type", "application/json")
req1.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp1, err := http.DefaultClient.Do(req1)
require.NoError(t, err)
resp1.Body.Close()
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
createdRobotIDs = append(createdRobotIDs, robotID)
// Second create with same ID should fail
body2, _ := json.Marshal(createData)
req2, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body2))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close()
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
})
}
// TestGetRobot tests the robot get endpoint
func TestGetRobot(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Robot Get Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test robot first
robotID := fmt.Sprintf("test_robot_get_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": "Test Robot Get",
"bio": "A robot for get test",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
createResp, err := http.DefaultClient.Do(createReq)
require.NoError(t, err)
createResp.Body.Close()
// Cleanup
defer func() {
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
http.DefaultClient.Do(req)
}()
t.Run("GetRobotSuccess", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, robotID, response["member_id"])
assert.Equal(t, "Test Robot Get", response["display_name"])
})
t.Run("GetRobotNotFound", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// TestUpdateRobot tests the robot update endpoint
func TestUpdateRobot(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Robot Update Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test robot first
robotID := fmt.Sprintf("test_robot_update_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": "Test Robot Update",
"bio": "Original bio",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
createResp, err := http.DefaultClient.Do(createReq)
require.NoError(t, err)
createResp.Body.Close()
// Cleanup
defer func() {
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
http.DefaultClient.Do(req)
}()
t.Run("UpdateRobotSuccess", func(t *testing.T) {
updateData := map[string]interface{}{
"display_name": "Updated Robot Name",
"bio": "Updated bio",
}
body, _ := json.Marshal(updateData)
req, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, "Updated Robot Name", response["display_name"])
assert.Equal(t, "Updated bio", response["bio"])
})
t.Run("UpdateRobotNotFound", func(t *testing.T) {
updateData := map[string]interface{}{
"display_name": "Updated Name",
}
body, _ := json.Marshal(updateData)
req, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/non_existent_robot", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// TestDeleteRobot tests the robot delete endpoint
func TestDeleteRobot(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Robot Delete Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("DeleteRobotSuccess", func(t *testing.T) {
// Create a test robot first
robotID := fmt.Sprintf("test_robot_delete_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": "Test Robot Delete",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
createResp, err := http.DefaultClient.Do(createReq)
require.NoError(t, err)
createResp.Body.Close()
// Delete the robot
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, true, response["deleted"])
assert.Equal(t, robotID, response["member_id"])
// Verify it's deleted by trying to get it
getReq, _ := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil)
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
getResp, err := http.DefaultClient.Do(getReq)
require.NoError(t, err)
defer getResp.Body.Close()
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
})
t.Run("DeleteRobotNotFound", func(t *testing.T) {
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/non_existent_robot", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// TestGetRobotStatus tests the robot status endpoint
func TestGetRobotStatus(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Robot Status Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test robot first
robotID := fmt.Sprintf("test_robot_status_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": "Test Robot Status",
"autonomous_mode": true,
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
createResp, err := http.DefaultClient.Do(createReq)
require.NoError(t, err)
createResp.Body.Close()
// Cleanup
defer func() {
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
http.DefaultClient.Do(req)
}()
t.Run("GetRobotStatusSuccess", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/status", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, robotID, response["member_id"])
assert.Contains(t, response, "status")
assert.Contains(t, response, "running")
assert.Contains(t, response, "max_running")
})
t.Run("GetRobotStatusNotFound", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/status", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// TestRobotPermissions tests robot permission scenarios
// Tests personal user vs team user access control
func TestRobotPermissions(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client
client := testutils.RegisterTestClient(t, "Robot Permission Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Create User 1 (Personal user - no team)
token1 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
user1ID := token1.UserID
// Create User 2 (Different user)
token2 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
user2ID := token2.UserID
t.Logf("Test users created: User1=%s, User2=%s", user1ID, user2ID)
// Track created robots for cleanup
var createdRobotIDs []string
defer func() {
for _, robotID := range createdRobotIDs {
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
http.DefaultClient.Do(req)
}
}()
t.Run("PersonalUserCreateRobot", func(t *testing.T) {
// Personal user creates a robot with their user_id as team_id
// This simulates a personal user (no team) creating their own robot
robotID := fmt.Sprintf("test_personal_robot_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": user1ID, // Personal user: team_id = user_id
"display_name": "Personal Robot",
"bio": "A robot created by a personal user",
}
body, _ := json.Marshal(createData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, robotID, response["member_id"])
assert.Equal(t, user1ID, response["team_id"])
t.Logf("Personal robot created: %s (team_id: %s)", robotID, user1ID)
createdRobotIDs = append(createdRobotIDs, robotID)
})
t.Run("PersonalUserCanAccessOwnRobot", func(t *testing.T) {
// User 1 creates a robot
robotID := fmt.Sprintf("test_own_robot_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": user1ID, // Personal user: team_id = user_id
"display_name": "User 1 Robot",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
createResp, _ := http.DefaultClient.Do(createReq)
createResp.Body.Close()
createdRobotIDs = append(createdRobotIDs, robotID)
// User 1 can access their own robot
getReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil)
require.NoError(t, err)
getReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
getResp, err := http.DefaultClient.Do(getReq)
require.NoError(t, err)
defer getResp.Body.Close()
assert.Equal(t, http.StatusOK, getResp.StatusCode)
t.Logf("User 1 successfully accessed their own robot: %s", robotID)
})
t.Run("PersonalUserCanUpdateOwnRobot", func(t *testing.T) {
// User 1 creates a robot
robotID := fmt.Sprintf("test_update_robot_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": user1ID,
"display_name": "Original Name",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
createResp, _ := http.DefaultClient.Do(createReq)
createResp.Body.Close()
createdRobotIDs = append(createdRobotIDs, robotID)
// User 1 can update their own robot
updateData := map[string]interface{}{
"display_name": "Updated Name",
}
updateBody, _ := json.Marshal(updateData)
updateReq, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(updateBody))
require.NoError(t, err)
updateReq.Header.Set("Content-Type", "application/json")
updateReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
updateResp, err := http.DefaultClient.Do(updateReq)
require.NoError(t, err)
defer updateResp.Body.Close()
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
var response map[string]interface{}
json.NewDecoder(updateResp.Body).Decode(&response)
assert.Equal(t, "Updated Name", response["display_name"])
t.Logf("User 1 successfully updated their own robot")
})
t.Run("PersonalUserCanDeleteOwnRobot", func(t *testing.T) {
// User 1 creates a robot
robotID := fmt.Sprintf("test_delete_robot_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": user1ID,
"display_name": "Robot to Delete",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
createResp, _ := http.DefaultClient.Do(createReq)
createResp.Body.Close()
// User 1 can delete their own robot
deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
require.NoError(t, err)
deleteReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
deleteResp, err := http.DefaultClient.Do(deleteReq)
require.NoError(t, err)
defer deleteResp.Body.Close()
assert.Equal(t, http.StatusOK, deleteResp.StatusCode)
t.Logf("User 1 successfully deleted their own robot")
})
t.Run("TeamRobotAccess", func(t *testing.T) {
// Create a robot with a shared team_id
sharedTeamID := fmt.Sprintf("team_%d", time.Now().UnixNano())
robotID := fmt.Sprintf("test_team_robot_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": sharedTeamID,
"display_name": "Team Robot",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
createResp, _ := http.DefaultClient.Do(createReq)
createResp.Body.Close()
createdRobotIDs = append(createdRobotIDs, robotID)
// Creator can access the team robot
getReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil)
require.NoError(t, err)
getReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
getResp, err := http.DefaultClient.Do(getReq)
require.NoError(t, err)
defer getResp.Body.Close()
assert.Equal(t, http.StatusOK, getResp.StatusCode)
t.Logf("Creator successfully accessed team robot: %s (team: %s)", robotID, sharedTeamID)
})
t.Run("VerifyYaoPermissionFieldsSet", func(t *testing.T) {
// Create a robot and verify __yao_created_by and __yao_team_id are set
robotID := fmt.Sprintf("test_perm_fields_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": user1ID, // Personal user: team_id = user_id
"display_name": "Permission Fields Test Robot",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
createResp, err := http.DefaultClient.Do(createReq)
require.NoError(t, err)
defer createResp.Body.Close()
createdRobotIDs = append(createdRobotIDs, robotID)
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
var response map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&response)
// The response should contain the robot data
// Note: __yao_created_by and __yao_team_id might not be in the public response
// but they should be set in the database
assert.Equal(t, robotID, response["member_id"])
t.Logf("Robot created with permission fields (user_id: %s)", user1ID)
})
t.Run("DifferentUserCannotUpdateRobot", func(t *testing.T) {
// User 1 creates a robot
robotID := fmt.Sprintf("test_cross_update_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": user1ID,
"display_name": "User 1 Private Robot",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
createResp, _ := http.DefaultClient.Do(createReq)
createResp.Body.Close()
createdRobotIDs = append(createdRobotIDs, robotID)
// User 2 attempts to update User 1's robot - should be denied
// Note: With system:root scope, this might still succeed due to admin privileges
// In production, user2 would not have system:root
updateData := map[string]interface{}{
"display_name": "Unauthorized Update",
}
updateBody, _ := json.Marshal(updateData)
updateReq, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(updateBody))
require.NoError(t, err)
updateReq.Header.Set("Content-Type", "application/json")
updateReq.Header.Set("Authorization", "Bearer "+token2.AccessToken)
updateResp, err := http.DefaultClient.Do(updateReq)
require.NoError(t, err)
defer updateResp.Body.Close()
// With system:root scope (no constraints), user2 can still update
// This test documents the current behavior with admin privileges
t.Logf("User 2 update attempt status: %d (with system:root scope)", updateResp.StatusCode)
})
t.Run("DifferentUserCannotDeleteRobot", func(t *testing.T) {
// User 1 creates a robot
robotID := fmt.Sprintf("test_cross_delete_%d", time.Now().UnixNano())
createData := map[string]interface{}{
"member_id": robotID,
"team_id": user1ID,
"display_name": "User 1 Robot for Delete Test",
}
body, _ := json.Marshal(createData)
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
createResp, _ := http.DefaultClient.Do(createReq)
createResp.Body.Close()
createdRobotIDs = append(createdRobotIDs, robotID)
// User 2 attempts to delete User 1's robot
// Note: With system:root scope, this might still succeed due to admin privileges
deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
require.NoError(t, err)
deleteReq.Header.Set("Authorization", "Bearer "+token2.AccessToken)
deleteResp, err := http.DefaultClient.Do(deleteReq)
require.NoError(t, err)
defer deleteResp.Body.Close()
// With system:root scope (no constraints), user2 can still delete
// This test documents the current behavior with admin privileges
t.Logf("User 2 delete attempt status: %d (with system:root scope)", deleteResp.StatusCode)
})
t.Run("ListRobotsWithTeamFilter", func(t *testing.T) {
// Create robots for both users
robot1ID := fmt.Sprintf("test_list_user1_%d", time.Now().UnixNano())
robot2ID := fmt.Sprintf("test_list_user2_%d", time.Now().UnixNano())
// User 1 creates their robot
create1 := map[string]interface{}{
"member_id": robot1ID,
"team_id": user1ID,
"display_name": "User 1 List Robot",
}
body1, _ := json.Marshal(create1)
req1, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body1))
req1.Header.Set("Content-Type", "application/json")
req1.Header.Set("Authorization", "Bearer "+token1.AccessToken)
resp1, _ := http.DefaultClient.Do(req1)
resp1.Body.Close()
createdRobotIDs = append(createdRobotIDs, robot1ID)
// User 2 creates their robot
create2 := map[string]interface{}{
"member_id": robot2ID,
"team_id": user2ID,
"display_name": "User 2 List Robot",
}
body2, _ := json.Marshal(create2)
req2, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body2))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+token2.AccessToken)
resp2, _ := http.DefaultClient.Do(req2)
resp2.Body.Close()
createdRobotIDs = append(createdRobotIDs, robot2ID)
// User 1 lists robots with their team_id filter
listReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?team_id="+user1ID, nil)
require.NoError(t, err)
listReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
listResp, err := http.DefaultClient.Do(listReq)
require.NoError(t, err)
defer listResp.Body.Close()
assert.Equal(t, http.StatusOK, listResp.StatusCode)
var response map[string]interface{}
json.NewDecoder(listResp.Body).Decode(&response)
data := response["data"].([]interface{})
t.Logf("User 1 sees %d robots with team_id=%s filter", len(data), user1ID)
})
}

View file

@ -0,0 +1,9 @@
{
"name": "Robot Prompt Generator",
"description": "Generate system prompts for autonomous robots",
"type": "worker",
"uses": { "search": "disabled" },
"options": {
"temperature": 0.7
}
}

View file

@ -0,0 +1,91 @@
- role: system
content: |
You are an expert at crafting system prompts for autonomous AI robots (agents).
Task:
Given a brief role description, generate a comprehensive system prompt that defines:
1. Identity: Who the robot is
2. Responsibilities: What the robot should do
3. Constraints: Rules and limitations
4. Style: Communication tone and approach
Output:
- Return ONLY the system prompt text
- NO markdown code blocks, NO quotes, NO explanation
- Just the prompt content itself
- Use the SAME LANGUAGE as the user's input
Structure (adapt based on role):
```
You are [role description].
## Core Responsibilities
- [duty 1]
- [duty 2]
- [duty 3]
## Working Principles
- [principle 1]
- [principle 2]
## Constraints
- [constraint 1]
- [constraint 2]
## Communication Style
- [style guideline]
```
Examples:
Input: "Sales Analyst"
Output:
You are a Sales Analyst responsible for analyzing sales data and providing actionable insights.
## Core Responsibilities
- Analyze daily/weekly/monthly sales trends
- Identify top-performing products and regions
- Generate sales forecast reports
- Alert on significant anomalies or opportunities
## Working Principles
- Always base conclusions on data, not assumptions
- Prioritize actionable insights over raw statistics
- Consider seasonal factors and market context
## Constraints
- Only access authorized sales databases
- Do not make pricing or strategy decisions
- Escalate sensitive findings to management
## Communication Style
- Clear, concise, business-focused language
- Use charts and tables when presenting data
- Lead with key findings, details follow
---
Input: "你是工程师"
Output:
你是一名专注于技术问题解决的工程师助手。
## 核心职责
- 分析和诊断技术问题
- 提供解决方案和最佳实践建议
- 编写和审查代码
- 监控系统健康状态
## 工作原则
- 先理解问题根因,再提供解决方案
- 优先考虑稳定性和可维护性
- 遵循团队编码规范和架构标准
## 约束条件
- 仅在授权范围内操作系统
- 重大变更需人工确认
- 不自行决定架构重构
## 沟通风格
- 技术准确,表达简洁
- 提供代码示例时注明语言和版本
- 复杂概念配合示意图说明