Merge pull request #1426 from trheyi/main
Implement Autonomous Agent API
This commit is contained in:
commit
ef65d4a2bb
56 changed files with 8669 additions and 3521 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2
agent/robot/cache/load.go
vendored
2
agent/robot/cache/load.go
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
640
agent/robot/store/robot.go
Normal 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
|
||||||
|
}
|
||||||
578
agent/robot/store/robot_test.go
Normal file
578
agent/robot/store/robot_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
567
agent/robot/utils/convert_test.go
Normal file
567
agent/robot/utils/convert_test.go
Normal 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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
380
data/bindata.go
380
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
840
openapi/agent/robot/DESIGN.md
Normal file
840
openapi/agent/robot/DESIGN.md
Normal 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
846
openapi/agent/robot/GAPS.md
Normal 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
816
openapi/agent/robot/TODO.md
Normal 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) |
|
||||||
432
openapi/agent/robot/detail.go
Normal file
432
openapi/agent/robot/detail.go
Normal 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
117
openapi/agent/robot/list.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
135
openapi/agent/robot/permission.go
Normal file
135
openapi/agent/robot/permission.go
Normal 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
|
||||||
|
}
|
||||||
25
openapi/agent/robot/robot.go
Normal file
25
openapi/agent/robot/robot.go
Normal 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
|
||||||
|
}
|
||||||
255
openapi/agent/robot/types.go
Normal file
255
openapi/agent/robot/types.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
101
openapi/agent/robot/utils.go
Normal file
101
openapi/agent/robot/utils.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
944
openapi/tests/agent/robot_test.go
Normal file
944
openapi/tests/agent/robot_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
9
yao/assistants/robot_prompt/package.yao
Normal file
9
yao/assistants/robot_prompt/package.yao
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
91
yao/assistants/robot_prompt/prompts.yml
Normal file
91
yao/assistants/robot_prompt/prompts.yml
Normal 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:
|
||||||
|
你是一名专注于技术问题解决的工程师助手。
|
||||||
|
|
||||||
|
## 核心职责
|
||||||
|
- 分析和诊断技术问题
|
||||||
|
- 提供解决方案和最佳实践建议
|
||||||
|
- 编写和审查代码
|
||||||
|
- 监控系统健康状态
|
||||||
|
|
||||||
|
## 工作原则
|
||||||
|
- 先理解问题根因,再提供解决方案
|
||||||
|
- 优先考虑稳定性和可维护性
|
||||||
|
- 遵循团队编码规范和架构标准
|
||||||
|
|
||||||
|
## 约束条件
|
||||||
|
- 仅在授权范围内操作系统
|
||||||
|
- 重大变更需人工确认
|
||||||
|
- 不自行决定架构重构
|
||||||
|
|
||||||
|
## 沟通风格
|
||||||
|
- 技术准确,表达简洁
|
||||||
|
- 提供代码示例时注明语言和版本
|
||||||
|
- 复杂概念配合示意图说明
|
||||||
Loading…
Add table
Reference in a new issue