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", "querydsl",
"title", "title",
"prompt", "prompt",
"robot_prompt",
"needsearch", "needsearch",
"entity", "entity",
} }
@ -31,13 +32,14 @@ var systemAgents = []string{
// SystemConfig holds the system agents connector configuration // SystemConfig holds the system agents connector configuration
// This is set from agent.yml system block // This is set from agent.yml system block
type SystemConfig struct { type SystemConfig struct {
Default string // Default connector for all system agents Default string // Default connector for all system agents
Keyword string // Connector for __yao.keyword agent Keyword string // Connector for __yao.keyword agent
QueryDSL string // Connector for __yao.querydsl agent QueryDSL string // Connector for __yao.querydsl agent
Title string // Connector for __yao.title agent Title string // Connector for __yao.title agent
Prompt string // Connector for __yao.prompt agent Prompt string // Connector for __yao.prompt agent
NeedSearch string // Connector for __yao.needsearch agent RobotPrompt string // Connector for __yao.robot_prompt agent
Entity string // Connector for __yao.entity 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) // 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 != "" { if systemConfig.Prompt != "" {
return systemConfig.Prompt return systemConfig.Prompt
} }
case "__yao.robot_prompt":
if systemConfig.RobotPrompt != "" {
return systemConfig.RobotPrompt
}
case "__yao.needsearch": case "__yao.needsearch":
if systemConfig.NeedSearch != "" { if systemConfig.NeedSearch != "" {
return 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 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 == "" { 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 == "" { 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 agentDSL = &setting

View file

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

View file

@ -82,11 +82,6 @@ yao/agent/robot/
│ ├── db.go # Database queries │ ├── db.go # Database queries
│ └── learning.go # Learning entry save (to KB) │ └── 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/ # Plan queue (deferred tasks)
├── plan.go # Plan queue struct ├── plan.go # Plan queue struct
└── schedule.go # Schedule for later └── 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/` | | `store/` | `types/` |
| `pool/` | `types/` | | `pool/` | `types/` |
| `trigger/` | `types/` | | `trigger/` | `types/` |
| `job/` | `types/`, `yao/job` |
| `plan/` | `types/` | | `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/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
| | Manager handles all trigger logic (clock, intervene, event) | | | Manager handles all trigger logic (clock, intervene, event) |
| `api/` | `types/`, `manager/` | | `api/` | `types/`, `manager/` |
@ -304,7 +298,6 @@ type TriggerResult struct {
Accepted bool `json:"accepted"` // whether trigger was accepted Accepted bool `json:"accepted"` // whether trigger was accepted
Queued bool `json:"queued"` // true if queued (quota full) Queued bool `json:"queued"` // true if queued (quota full)
Execution *types.Execution `json:"execution,omitempty"` // execution info if started 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 Message string `json:"message,omitempty"` // status message
} }
@ -524,7 +517,6 @@ interface TriggerResult {
accepted: boolean; accepted: boolean;
queued: boolean; queued: boolean;
execution?: Execution; execution?: Execution;
job_id?: string;
message?: string; message?: string;
} }
@ -1160,7 +1152,7 @@ import (
// Robot - runtime representation of an autonomous robot (from __yao.member) // Robot - runtime representation of an autonomous robot (from __yao.member)
// Relationship: 1 Robot : N Executions (concurrent) // 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 { type Robot struct {
// From __yao.member // From __yao.member
MemberID string `json:"member_id"` MemberID string `json:"member_id"`
@ -1234,8 +1226,7 @@ func (r *Robot) GetExecutions() []*Execution {
} }
// Execution - single execution instance // Execution - single execution instance
// Each trigger creates a new Execution, mapped to a job.Job for monitoring // Each trigger creates a new Execution, stored in ExecutionStore
// Relationship: 1 Execution = 1 job.Job
type Execution struct { type Execution struct {
ID string `json:"id"` // unique execution ID ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID (globally unique) MemberID string `json:"member_id"` // robot member ID (globally unique)
@ -1247,8 +1238,6 @@ type Execution struct {
Phase Phase `json:"phase"` Phase Phase `json:"phase"`
Error string `json:"error,omitempty"` 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) // Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input Input *TriggerInput `json:"input,omitempty"` // original trigger input
@ -2392,7 +2381,6 @@ type ExecutionRecord struct {
ExecutionID string `json:"execution_id"` // Unique execution identifier ExecutionID string `json:"execution_id"` // Unique execution identifier
MemberID string `json:"member_id"` // Robot member ID (globally unique) MemberID string `json:"member_id"` // Robot member ID (globally unique)
TeamID string `json:"team_id"` // Team ID 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 TriggerType TriggerType `json:"trigger_type"` // clock | human | event
// Status tracking (synced with runtime Execution) // 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] `dedup/dedup.go` - Dedup struct, stub methods
- [x] `store/store.go` - Store struct, stub methods - [x] `store/store.go` - Store struct, stub methods
- [x] `pool/pool.go` - Pool 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] `plan/plan.go` - Plan struct, stub methods
- [x] `trigger/trigger.go` - trigger dispatcher stub - [x] `trigger/trigger.go` - trigger dispatcher stub
- [x] `executor/executor.go` - Executor struct, stub `Execute()` - [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] ExecutionController lifecycle tests
- [x] Manager integration tests for Intervene/HandleEvent - [x] Manager integration tests for Intervene/HandleEvent
### ✅ 3.5 Job Integration (COMPLETE) ### ✅ 3.5 Execution Storage (COMPLETE)
- [x] `job/job.go` - create job - [x] ExecutionStore - execution record persistence
- [x] `job_id`: `robot_exec_{execID}` - [x] Execution data stored in `__yao.agent_execution` table
- [x] `category_name`: `Autonomous Robot` / `自主机器人` (localized) - [x] All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning)
- [x] Metadata: member_id, team_id, trigger_type, exec_id, display_name - [x] Status and phase tracking
- [x] `Options` struct for extensibility (Priority, MaxRetryCount, DefaultTimeout, Metadata) - [x] Logging via `kun/log` package
- [x] `Create()`, `Get()`, `Update()`, `Complete()`, `Fail()`, `Cancel()`
- [x] Status mapping: ExecPending→queued, ExecRunning→running, etc.
- [x] Localization support (en-US, zh-CN) - [x] Localization support (en-US, zh-CN)
- [x] `job/execution.go` - execution lifecycle - [x] Test: execution storage, status tracking
- [x] `CreateOptions` struct for extensibility - [x] `store/execution_test.go` - execution store tests
- [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] All tests passing with real database - [x] All tests passing with real database
### ✅ 3.6 Executor Architecture (COMPLETE) ### ✅ 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] `yao/models/agent/execution.mod.yao` - Execution record model (`agent_execution` table)
- [x] id, execution_id (unique) - [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] trigger_type (enum: clock, human, event)
- [x] **Status tracking** (synced with runtime Execution): - [x] **Status tracking** (synced with runtime Execution):
- [x] status (enum: pending, running, completed, failed, cancelled) - [x] status (enum: pending, running, completed, failed, cancelled)

View file

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

View file

@ -102,7 +102,7 @@ func TestAPIFullLifecycle(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, triggerResult) require.NotNil(t, triggerResult)
assert.True(t, triggerResult.Accepted) assert.True(t, triggerResult.Accepted)
assert.NotEmpty(t, triggerResult.JobID) assert.NotEmpty(t, triggerResult.ExecutionID)
// 7. Wait for execution to complete // 7. Wait for execution to complete
time.Sleep(500 * time.Millisecond) 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 // TestAPIExecutionQueryWithData tests execution query APIs with real data
func TestAPIExecutionQueryWithData(t *testing.T) { func TestAPIExecutionQueryWithData(t *testing.T) {
if testing.Short() { if testing.Short() {
@ -333,7 +402,7 @@ func TestAPITriggerWithData(t *testing.T) {
require.NotNil(t, result) require.NotNil(t, result)
assert.True(t, result.Accepted) assert.True(t, result.Accepted)
assert.NotEmpty(t, result.JobID) assert.NotEmpty(t, result.ExecutionID)
assert.Contains(t, result.Message, "submitted") assert.Contains(t, result.Message, "submitted")
}) })
@ -377,6 +446,44 @@ func TestAPITriggerWithData(t *testing.T) {
// ==================== Helper Functions ==================== // ==================== 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 // setupAPITestRobot creates a test robot in the database
func setupAPITestRobot(t *testing.T, memberID, teamID string) { func setupAPITestRobot(t *testing.T, memberID, teamID string) {
m := model.Select("__yao.member") m := model.Select("__yao.member")
@ -435,7 +542,6 @@ func setupAPITestExecution(t *testing.T, execID, memberID string, triggerType ty
ExecutionID: execID, ExecutionID: execID,
MemberID: memberID, MemberID: memberID,
TeamID: "team_api_exec", TeamID: "team_api_exec",
JobID: "job_" + execID,
TriggerType: triggerType, TriggerType: triggerType,
Status: status, Status: status,
Phase: types.PhaseDelivery, Phase: types.PhaseDelivery,
@ -462,11 +568,17 @@ func cleanupAPITestRobots(t *testing.T) {
tableName := m.MetaData.Table.Name tableName := m.MetaData.Table.Name
qb := capsule.Query() 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() _, err := qb.Table(tableName).Where("member_id", "like", "robot_api_%").Delete()
if err != nil { if err != nil {
t.Logf("Warning: cleanup robots error: %v", err) 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 // cleanupAPITestExecutions removes all API test executions

View file

@ -71,9 +71,9 @@ func TestE2EClockTriggerFullFlow(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, result) require.NotNil(t, result)
assert.True(t, result.Accepted, "Clock trigger should be accepted: %s", result.Message) 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) // Wait for execution to complete (real LLM calls take time)
// P0→P4 typically takes 30-60 seconds with real LLM // P0→P4 typically takes 30-60 seconds with real LLM

View file

@ -90,7 +90,7 @@ func TestE2EConcurrentMultipleRobots(t *testing.T) {
if result.Accepted { if result.Accepted {
acceptedCount.Add(1) 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) }(i, memberID)
} }
@ -176,7 +176,7 @@ func TestE2EConcurrentSameRobotMultipleTriggers(t *testing.T) {
if result.Accepted { if result.Accepted {
acceptedCount.Add(1) acceptedCount.Add(1)
t.Logf("Trigger %d accepted: JobID=%s", idx, result.JobID) t.Logf("Trigger %d accepted: ExecutionID=%s", idx, result.ExecutionID)
} else { } else {
t.Logf("Trigger %d rejected: %s", idx, result.Message) 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.NoError(t, err)
require.True(t, result.Accepted) 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 // Wait for execution to start running
var execID string var execID string
@ -166,7 +166,7 @@ func TestE2EControlStop(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.True(t, result.Accepted) 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 // Wait for execution to start running
var execID string var execID string

View file

@ -83,7 +83,7 @@ func TestE2EEventTriggerFullFlow(t *testing.T) {
require.NotNil(t, result) require.NotNil(t, result)
assert.True(t, result.Accepted, "Event trigger should be accepted") 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 // Wait for execution to complete
var exec *types.Execution var exec *types.Execution
@ -256,7 +256,7 @@ func TestE2EEventTriggerVariousEventTypes(t *testing.T) {
require.NotNil(t, result) require.NotNil(t, result)
assert.True(t, result.Accepted, "Event should be accepted") 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 // Wait for execution
maxWait := 120 * time.Second maxWait := 120 * time.Second

View file

@ -1,10 +1,14 @@
package api package api
import ( import (
"context"
"fmt" "fmt"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/yaoapp/gou/model" "github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps" "github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/agent/robot/store"
"github.com/yaoapp/yao/agent/robot/types" "github.com/yaoapp/yao/agent/robot/types"
) )
@ -14,6 +18,9 @@ import (
// memberModel is the model name for member table // memberModel is the model name for member table
const memberModel = "__yao.member" const memberModel = "__yao.member"
// robotStore is the shared robot store instance
var robotStore = store.NewRobotStore()
// GetRobot returns a robot by member ID // GetRobot returns a robot by member ID
// Returns the robot from cache if available, otherwise loads from database // Returns the robot from cache if available, otherwise loads from database
func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) { 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 return nil, err
} }
// Get permission fields from store (for access control)
record, _ := robotStore.Get(context.Background(), memberID)
state := &RobotState{ state := &RobotState{
MemberID: robot.MemberID, MemberID: robot.MemberID,
TeamID: robot.TeamID, TeamID: robot.TeamID,
DisplayName: robot.DisplayName, DisplayName: robot.DisplayName,
Bio: robot.Bio,
Status: robot.Status, Status: robot.Status,
Running: robot.RunningCount(), Running: robot.RunningCount(),
MaxRunning: 2, // default 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 { if robot.Config != nil && robot.Config.Quota != nil {
state.MaxRunning = robot.Config.Quota.GetMax() state.MaxRunning = robot.Config.Quota.GetMax()
} }
@ -121,7 +138,7 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) {
records, err := m.Get(model.QueryParam{ records, err := m.Get(model.QueryParam{
Select: []interface{}{ Select: []interface{}{
"id", "member_id", "team_id", "display_name", "id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode", "system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email", "robot_config", "robot_email",
}, },
@ -152,7 +169,6 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
// Build where conditions // Build where conditions
wheres := []model.QueryWhere{ wheres := []model.QueryWhere{
{Column: "member_type", Value: "robot"}, {Column: "member_type", Value: "robot"},
{Column: "autonomous_mode", Value: true},
{Column: "status", Value: "active"}, {Column: "status", Value: "active"},
} }
@ -169,6 +185,9 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
Value: "%" + query.Keywords + "%", Value: "%" + query.Keywords + "%",
}) })
} }
if query.AutonomousMode != nil {
wheres = append(wheres, model.QueryWhere{Column: "autonomous_mode", Value: *query.AutonomousMode})
}
// Build order // Build order
orders := []model.QueryOrder{} orders := []model.QueryOrder{}
@ -181,7 +200,7 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
// Execute paginated query // Execute paginated query
result, err := m.Paginate(model.QueryParam{ result, err := m.Paginate(model.QueryParam{
Select: []interface{}{ Select: []interface{}{
"id", "member_id", "team_id", "display_name", "id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode", "system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email", "robot_config", "robot_email",
}, },
@ -256,3 +275,375 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult {
PageSize: query.PageSize, 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" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/robot/api" "github.com/yaoapp/yao/agent/robot/api"
"github.com/yaoapp/yao/agent/robot/types" "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils" "github.com/yaoapp/yao/agent/testutils"
@ -100,3 +101,376 @@ func TestGetRobotStatusValidation(t *testing.T) {
assert.Nil(t, status) 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{ return &TriggerResult{
Accepted: true, Accepted: true,
JobID: execID, ExecutionID: execID,
Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType), Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType),
}, nil }, nil
} }
@ -123,9 +123,9 @@ func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req
} }
return &TriggerResult{ return &TriggerResult{
Accepted: true, Accepted: true,
JobID: result.ExecutionID, ExecutionID: result.ExecutionID,
Message: result.Message, Message: result.Message,
}, nil }, nil
} }
@ -150,9 +150,9 @@ func triggerEvent(ctx *types.Context, mgr managerInterface, memberID string, req
} }
return &TriggerResult{ return &TriggerResult{
Accepted: true, Accepted: true,
JobID: result.ExecutionID, ExecutionID: result.ExecutionID,
Message: result.Message, Message: result.Message,
}, nil }, nil
} }
@ -173,9 +173,9 @@ func triggerManual(ctx *types.Context, mgr managerInterface, memberID string, re
} }
return &TriggerResult{ return &TriggerResult{
Accepted: true, Accepted: true,
JobID: execID, ExecutionID: execID,
Message: fmt.Sprintf("Trigger (%s) submitted", req.Type), Message: fmt.Sprintf("Trigger (%s) submitted", req.Type),
}, nil }, nil
} }

View file

@ -9,13 +9,14 @@ import (
// ListQuery - query options for List() // ListQuery - query options for List()
type ListQuery struct { type ListQuery struct {
TeamID string `json:"team_id,omitempty"` TeamID string `json:"team_id,omitempty"`
Status types.RobotStatus `json:"status,omitempty"` Status types.RobotStatus `json:"status,omitempty"`
Keywords string `json:"keywords,omitempty"` Keywords string `json:"keywords,omitempty"`
ClockMode types.ClockMode `json:"clock_mode,omitempty"` ClockMode types.ClockMode `json:"clock_mode,omitempty"`
Page int `json:"page,omitempty"` AutonomousMode *bool `json:"autonomous_mode,omitempty"` // nil=all, true=autonomous only, false=on-demand only
PageSize int `json:"pagesize,omitempty"` Page int `json:"page,omitempty"`
Order string `json:"order,omitempty"` PageSize int `json:"pagesize,omitempty"`
Order string `json:"order,omitempty"`
} }
// ListResult - result of List() // ListResult - result of List()
@ -28,15 +29,18 @@ type ListResult struct {
// RobotState - runtime state from Status() // RobotState - runtime state from Status()
type RobotState struct { type RobotState struct {
MemberID string `json:"member_id"` MemberID string `json:"member_id"`
TeamID string `json:"team_id"` TeamID string `json:"team_id"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Status types.RobotStatus `json:"status"` Bio string `json:"bio,omitempty"`
Running int `json:"running"` Status types.RobotStatus `json:"status"`
MaxRunning int `json:"max_running"` Running int `json:"running"`
LastRun *time.Time `json:"last_run,omitempty"` MaxRunning int `json:"max_running"`
NextRun *time.Time `json:"next_run,omitempty"` LastRun *time.Time `json:"last_run,omitempty"`
RunningIDs []string `json:"running_ids,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 ==================== // ==================== Trigger Types ====================
@ -78,11 +82,11 @@ const (
// TriggerResult - result of Trigger() // TriggerResult - result of Trigger()
type TriggerResult struct { type TriggerResult struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Queued bool `json:"queued"` Queued bool `json:"queued"`
Execution *types.Execution `json:"execution,omitempty"` Execution *types.Execution `json:"execution,omitempty"`
JobID string `json:"job_id,omitempty"` ExecutionID string `json:"execution_id,omitempty"` // Execution ID
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }
// ==================== Execution Types ==================== // ==================== Execution Types ====================
@ -103,6 +107,136 @@ type ExecutionResult struct {
PageSize int `json:"pagesize"` 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 ==================== // ==================== Helper Functions ====================
// applyDefaults applies default values to ListQuery // applyDefaults applies default values to ListQuery

View file

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

View file

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

View file

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

View file

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

View file

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

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 // cleanupTestRobots removes all test robot records
func cleanupTestRobots(t *testing.T) { func cleanupTestRobots(t *testing.T) {
qb := capsule.Query() qb := capsule.Query()
@ -1417,6 +1683,10 @@ func cleanupTestRobots(t *testing.T) {
"robot_test_manager_intervene_disabled", "robot_test_manager_intervene_disabled",
"robot_test_manager_event", "robot_test_manager_event",
"robot_test_manager_event_disabled", "robot_test_manager_event_disabled",
// Non-autonomous robots
"robot_test_manager_on_demand",
"robot_test_manager_on_demand_intervene",
"robot_test_manager_on_demand_event",
} }
for _, id := range testRobotIDs { for _, id := range testRobotIDs {

View file

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

View file

@ -35,7 +35,6 @@ func TestExecutionStoreSave(t *testing.T) {
ExecutionID: "exec_test_save_001", ExecutionID: "exec_test_save_001",
MemberID: "member_test_001", MemberID: "member_test_001",
TeamID: "team_test_001", TeamID: "team_test_001",
JobID: "job_test_001",
TriggerType: types.TriggerClock, TriggerType: types.TriggerClock,
Status: types.ExecPending, Status: types.ExecPending,
Phase: types.PhaseInspiration, Phase: types.PhaseInspiration,
@ -53,7 +52,6 @@ func TestExecutionStoreSave(t *testing.T) {
assert.Equal(t, "exec_test_save_001", saved.ExecutionID) assert.Equal(t, "exec_test_save_001", saved.ExecutionID)
assert.Equal(t, "member_test_001", saved.MemberID) assert.Equal(t, "member_test_001", saved.MemberID)
assert.Equal(t, "team_test_001", saved.TeamID) 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.TriggerClock, saved.TriggerType)
assert.Equal(t, types.ExecPending, saved.Status) assert.Equal(t, types.ExecPending, saved.Status)
assert.Equal(t, types.PhaseInspiration, saved.Phase) assert.Equal(t, types.PhaseInspiration, saved.Phase)
@ -574,7 +572,6 @@ func TestExecutionRecordConversion(t *testing.T) {
ID: "exec_convert_001", ID: "exec_convert_001",
MemberID: "member_convert_001", MemberID: "member_convert_001",
TeamID: "team_convert_001", TeamID: "team_convert_001",
JobID: "job_convert_001",
TriggerType: types.TriggerHuman, TriggerType: types.TriggerHuman,
Status: types.ExecCompleted, Status: types.ExecCompleted,
Phase: types.PhaseDelivery, Phase: types.PhaseDelivery,
@ -600,7 +597,6 @@ func TestExecutionRecordConversion(t *testing.T) {
assert.Equal(t, "exec_convert_001", record.ExecutionID) assert.Equal(t, "exec_convert_001", record.ExecutionID)
assert.Equal(t, "member_convert_001", record.MemberID) assert.Equal(t, "member_convert_001", record.MemberID)
assert.Equal(t, "team_convert_001", record.TeamID) 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.TriggerHuman, record.TriggerType)
assert.Equal(t, types.ExecCompleted, record.Status) assert.Equal(t, types.ExecCompleted, record.Status)
assert.Equal(t, types.PhaseDelivery, record.Phase) assert.Equal(t, types.PhaseDelivery, record.Phase)
@ -621,7 +617,6 @@ func TestExecutionRecordConversion(t *testing.T) {
ExecutionID: "exec_convert_002", ExecutionID: "exec_convert_002",
MemberID: "member_convert_002", MemberID: "member_convert_002",
TeamID: "team_convert_002", TeamID: "team_convert_002",
JobID: "job_convert_002",
TriggerType: types.TriggerClock, TriggerType: types.TriggerClock,
Status: types.ExecRunning, Status: types.ExecRunning,
Phase: types.PhaseRun, Phase: types.PhaseRun,
@ -646,7 +641,6 @@ func TestExecutionRecordConversion(t *testing.T) {
assert.Equal(t, "exec_convert_002", exec.ID) assert.Equal(t, "exec_convert_002", exec.ID)
assert.Equal(t, "member_convert_002", exec.MemberID) assert.Equal(t, "member_convert_002", exec.MemberID)
assert.Equal(t, "team_convert_002", exec.TeamID) 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.TriggerClock, exec.TriggerType)
assert.Equal(t, types.ExecRunning, exec.Status) assert.Equal(t, types.ExecRunning, exec.Status)
assert.Equal(t, types.PhaseRun, exec.Phase) 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", ExecutionID: "exec_test_get_001",
MemberID: "member_test_get", MemberID: "member_test_get",
TeamID: "team_test_get", TeamID: "team_test_get",
JobID: "job_test_get",
TriggerType: types.TriggerClock, TriggerType: types.TriggerClock,
Status: types.ExecCompleted, Status: types.ExecCompleted,
Phase: types.PhaseDelivery, 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) // Robot - runtime representation of an autonomous robot (from __yao.member)
// Relationship: 1 Robot : N Executions (concurrent) // 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 { type Robot struct {
// From __yao.member // From __yao.member
MemberID string `json:"member_id"` MemberID string `json:"member_id"`
TeamID string `json:"team_id"` TeamID string `json:"team_id"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Bio string `json:"bio"` // Robot's description (from __yao.member.bio)
SystemPrompt string `json:"system_prompt"` SystemPrompt string `json:"system_prompt"`
Status RobotStatus `json:"robot_status"` Status RobotStatus `json:"robot_status"`
AutonomousMode bool `json:"autonomous_mode"` AutonomousMode bool `json:"autonomous_mode"`
@ -116,8 +117,7 @@ func (r *Robot) GetExecutions() []*Execution {
} }
// Execution - single execution instance // Execution - single execution instance
// Each trigger creates a new Execution, mapped to a job.Job for monitoring // Each trigger creates a new Execution, stored in ExecutionStore
// Relationship: 1 Execution = 1 job.Job
type Execution struct { type Execution struct {
ID string `json:"id"` // unique execution ID ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID MemberID string `json:"member_id"` // robot member ID
@ -129,9 +129,6 @@ type Execution struct {
Phase Phase `json:"phase"` Phase Phase `json:"phase"`
Error string `json:"error,omitempty"` 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) // Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input Input *TriggerInput `json:"input,omitempty"` // original trigger input
@ -381,6 +378,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
MemberID: memberID, MemberID: memberID,
TeamID: teamID, TeamID: teamID,
DisplayName: getString(m, "display_name"), DisplayName: getString(m, "display_name"),
Bio: getString(m, "bio"),
SystemPrompt: getString(m, "system_prompt"), SystemPrompt: getString(m, "system_prompt"),
AutonomousMode: getBool(m, "autonomous_mode"), AutonomousMode: getBool(m, "autonomous_mode"),
RobotEmail: getString(m, "robot_email"), RobotEmail: getString(m, "robot_email"),

View file

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

View file

@ -3,8 +3,384 @@ package utils
import ( import (
"encoding/json" "encoding/json"
"fmt" "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 // ToJSON converts any value to JSON string
func ToJSON(v interface{}) (string, error) { func ToJSON(v interface{}) (string, error) {
data, err := json.Marshal(v) data, err := json.Marshal(v)
@ -14,7 +390,7 @@ func ToJSON(v interface{}) (string, error) {
return string(data), nil return string(data), nil
} }
// FromJSON parses JSON string to target // FromJSON parses JSON string to target struct
func FromJSON(jsonStr string, target interface{}) error { func FromJSON(jsonStr string, target interface{}) error {
return json.Unmarshal([]byte(jsonStr), target) return json.Unmarshal([]byte(jsonStr), target)
} }
@ -25,12 +401,10 @@ func ToMap(v interface{}) (map[string]interface{}, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result map[string]interface{} var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil { if err := json.Unmarshal(data, &result); err != nil {
return nil, err return nil, err
} }
return result, nil return result, nil
} }
@ -43,29 +417,7 @@ func FromMap(m map[string]interface{}, target interface{}) error {
return json.Unmarshal(data, target) return json.Unmarshal(data, target)
} }
// ToString converts any value to string // ==================== Map Utilities ====================
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)
}
}
// MergeMap merges source map into target map (shallow copy) // MergeMap merges source map into target map (shallow copy)
func MergeMap(target, source map[string]interface{}) map[string]interface{} { 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 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 // Used for filtering and pagination when retrieving assistant lists
type AssistantFilter struct { type AssistantFilter struct {
Tags []string `json:"tags,omitempty"` // Filter by tags 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 Keywords string `json:"keywords,omitempty"` // Search in name and description
Connector string `json:"connector,omitempty"` // Filter by connector Connector string `json:"connector,omitempty"` // Filter by connector
AssistantID string `json:"assistant_id,omitempty"` // Filter by assistant ID 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 != "" { if filter.Type != "" {
qb.Where("type", 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 // Apply connector filter if provided
if filter.Connector != "" { if filter.Connector != "" {
qb.Where("connector", filter.Connector) qb.Where("connector", filter.Connector)

View file

@ -40,13 +40,14 @@ type DSL struct {
// Uses the default assistant settings // Uses the default assistant settings
// =============================== // ===============================
type Uses struct { type Uses struct {
Default string `json:"default,omitempty" yaml:"default,omitempty"` // The default assistant to use 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. 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. 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" RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // The assistant for generating Robot's system prompt (responsibilities description).
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" 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"
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. 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"
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 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) // Search-related processing tools (NLP)
Web string `json:"web,omitempty" yaml:"web,omitempty"` // Web search handler: "builtin", "<assistant-id>", "mcp:<server>.<tool>" 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 // System configures connectors for system agents
// =============================== // ===============================
type System struct { type System struct {
Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents 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 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 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 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 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 RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // Connector for __yao.robot_prompt agent
Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity 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 // Mention Structure

File diff suppressed because one or more lines are too long

View file

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

View file

@ -2,6 +2,7 @@ package agent
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/agent/robot"
"github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/oauth/types"
) )
@ -26,4 +27,8 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// Assistant Actions // Assistant Actions
// group.POST("/assistants/:id/call", agent.HandleAssistantCall) // POST /assistants/:id/call - Execute assistant API // 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 // Parse filter parameters
keywords := strings.TrimSpace(c.Query("keywords")) keywords := strings.TrimSpace(c.Query("keywords"))
typeParam := strings.TrimSpace(c.Query("type")) typeParam := strings.TrimSpace(c.Query("type"))
if typeParam == "" {
typeParam = "assistant" // Default type
}
connector := strings.TrimSpace(c.Query("connector")) connector := strings.TrimSpace(c.Query("connector"))
assistantID := strings.TrimSpace(c.Query("assistant_id")) 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) // Parse assistant IDs (multiple)
var assistantIDs []string var assistantIDs []string
if assistantIDsParam := c.Query("assistant_ids"); assistantIDsParam != "" { if assistantIDsParam := c.Query("assistant_ids"); assistantIDsParam != "" {
@ -131,6 +143,7 @@ func ListAssistants(c *gin.Context) {
PageSize: pagesize, PageSize: pagesize,
Keywords: keywords, Keywords: keywords,
Type: typeParam, Type: typeParam,
Types: types,
Connector: connector, Connector: connector,
AssistantID: assistantID, AssistantID: assistantID,
AssistantIDs: assistantIDs, 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 Page int
PageSize int PageSize int
Keywords string Keywords string
Type string Type string // Single type filter
Types []string // Multiple types filter (IN query)
Connector string Connector string
AssistantID string AssistantID string
AssistantIDs []string AssistantIDs []string
@ -70,6 +71,7 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt
Keywords: params.Keywords, Keywords: params.Keywords,
Tags: params.Tags, Tags: params.Tags,
Type: params.Type, Type: params.Type,
Types: params.Types,
Connector: params.Connector, Connector: params.Connector,
AssistantID: params.AssistantID, AssistantID: params.AssistantID,
AssistantIDs: params.AssistantIDs, AssistantIDs: params.AssistantIDs,
@ -79,8 +81,8 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt
Automated: params.Automated, Automated: params.Automated,
} }
// Set default type if not specified // Set default type if not specified (only when Types is also empty)
if filter.Type == "" { if filter.Type == "" && len(filter.Types) == 0 {
filter.Type = "assistant" 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:
你是一名专注于技术问题解决的工程师助手。
## 核心职责
- 分析和诊断技术问题
- 提供解决方案和最佳实践建议
- 编写和审查代码
- 监控系统健康状态
## 工作原则
- 先理解问题根因,再提供解决方案
- 优先考虑稳定性和可维护性
- 遵循团队编码规范和架构标准
## 约束条件
- 仅在授权范围内操作系统
- 重大变更需人工确认
- 不自行决定架构重构
## 沟通风格
- 技术准确,表达简洁
- 提供代码示例时注明语言和版本
- 复杂概念配合示意图说明