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",
|
||||
"title",
|
||||
"prompt",
|
||||
"robot_prompt",
|
||||
"needsearch",
|
||||
"entity",
|
||||
}
|
||||
|
|
@ -31,13 +32,14 @@ var systemAgents = []string{
|
|||
// SystemConfig holds the system agents connector configuration
|
||||
// This is set from agent.yml system block
|
||||
type SystemConfig struct {
|
||||
Default string // Default connector for all system agents
|
||||
Keyword string // Connector for __yao.keyword agent
|
||||
QueryDSL string // Connector for __yao.querydsl agent
|
||||
Title string // Connector for __yao.title agent
|
||||
Prompt string // Connector for __yao.prompt agent
|
||||
NeedSearch string // Connector for __yao.needsearch agent
|
||||
Entity string // Connector for __yao.entity agent
|
||||
Default string // Default connector for all system agents
|
||||
Keyword string // Connector for __yao.keyword agent
|
||||
QueryDSL string // Connector for __yao.querydsl agent
|
||||
Title string // Connector for __yao.title agent
|
||||
Prompt string // Connector for __yao.prompt agent
|
||||
RobotPrompt string // Connector for __yao.robot_prompt agent
|
||||
NeedSearch string // Connector for __yao.needsearch agent
|
||||
Entity string // Connector for __yao.entity agent
|
||||
}
|
||||
|
||||
// systemConfig holds the system agents configuration (global variable like others in load.go)
|
||||
|
|
@ -223,6 +225,10 @@ func resolveSystemConnector(agentID string) string {
|
|||
if systemConfig.Prompt != "" {
|
||||
return systemConfig.Prompt
|
||||
}
|
||||
case "__yao.robot_prompt":
|
||||
if systemConfig.RobotPrompt != "" {
|
||||
return systemConfig.RobotPrompt
|
||||
}
|
||||
case "__yao.needsearch":
|
||||
if systemConfig.NeedSearch != "" {
|
||||
return systemConfig.NeedSearch
|
||||
|
|
|
|||
|
|
@ -52,14 +52,19 @@ func Load(cfg config.Config) error {
|
|||
setting.Uses = &types.Uses{Default: "mohe"} // Agent is the developer name, Mohe is the brand name of the assistant
|
||||
}
|
||||
|
||||
// Title Assistant
|
||||
// Title Assistant (default to system agent)
|
||||
if setting.Uses.Title == "" {
|
||||
setting.Uses.Title = setting.Uses.Default
|
||||
setting.Uses.Title = "__yao.title"
|
||||
}
|
||||
|
||||
// Prompt Assistant
|
||||
// Prompt Assistant (default to system agent)
|
||||
if setting.Uses.Prompt == "" {
|
||||
setting.Uses.Prompt = setting.Uses.Default
|
||||
setting.Uses.Prompt = "__yao.prompt"
|
||||
}
|
||||
|
||||
// RobotPrompt Assistant (default to system agent)
|
||||
if setting.Uses.RobotPrompt == "" {
|
||||
setting.Uses.RobotPrompt = "__yao.robot_prompt"
|
||||
}
|
||||
|
||||
agentDSL = &setting
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ flowchart TB
|
|||
subgraph Storage["Storage"]
|
||||
KB[("KB")]
|
||||
DB[("DB")]
|
||||
Job[("Job")]
|
||||
end
|
||||
|
||||
WC --> TC
|
||||
|
|
@ -75,7 +74,7 @@ flowchart TB
|
|||
TT -->|Clock| P0
|
||||
TT -->|Human/Event| P1
|
||||
P0 --> P1 --> P2 --> P3 --> P4 --> P5
|
||||
P5 --> KB & DB & Job
|
||||
P5 --> KB & DB
|
||||
KB -.->|History| P0
|
||||
```
|
||||
|
||||
|
|
@ -89,7 +88,7 @@ Executor supports multiple execution modes for different use cases:
|
|||
| DryRun | Tests, demos, preview without LLM calls | ✅ Implemented |
|
||||
| Sandbox | Container-isolated for untrusted code | ⬜ Not Implemented |
|
||||
|
||||
**Standard Mode:** Real execution with LLM calls, Job integration, full phase execution.
|
||||
**Standard Mode:** Real execution with LLM calls, full phase execution, logging via kun/log.
|
||||
|
||||
**DryRun Mode:** Simulated execution without LLM calls. Used for:
|
||||
|
||||
|
|
@ -1009,15 +1008,13 @@ stateDiagram-v2
|
|||
2. Generate member_id if missing
|
||||
3. Create KB: `robot_{team_id}_{member_id}_kb`
|
||||
4. Add to cache
|
||||
5. Create Job
|
||||
6. Set active
|
||||
5. Set active
|
||||
|
||||
### 6.3 On Delete
|
||||
|
||||
1. Stop running jobs
|
||||
1. Stop running executions
|
||||
2. Remove from cache
|
||||
3. Delete Job
|
||||
4. Delete or archive KB
|
||||
3. Delete or archive KB
|
||||
5. Soft delete record
|
||||
|
||||
### 6.4 Execution Flow
|
||||
|
|
@ -1065,35 +1062,19 @@ stateDiagram-v2
|
|||
|
||||
## 7. Integrations
|
||||
|
||||
### 7.1 Job System
|
||||
### 7.1 Execution Storage
|
||||
|
||||
**Relationship:** 1 Robot : N Executions (concurrent), 1 Execution = 1 job.Job
|
||||
**Relationship:** 1 Robot : N Executions (concurrent)
|
||||
|
||||
Each trigger creates a new Execution, mapped to a `job.Job` for monitoring.
|
||||
Each trigger creates a new Execution, stored in `ExecutionStore` (`__yao.agent_execution` table).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Activity Monitor (UI) │
|
||||
│ • List jobs │
|
||||
│ • See progress │
|
||||
│ • View logs │
|
||||
│ • Cancel/retry │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Job Framework │
|
||||
│ Job → Execution → Progress → Logs │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
Execution data includes:
|
||||
- Status and phase tracking
|
||||
- All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning)
|
||||
- Error information
|
||||
- Timestamps and progress
|
||||
|
||||
**Go APIs (yao/job package):**
|
||||
|
||||
| Action | API |
|
||||
| ------------ | -------------------------------------------------------- |
|
||||
| List Jobs | `job.ListJobs(param, page, pagesize)` |
|
||||
| Get Job | `job.GetJob(jobID, param)` |
|
||||
| Save Job | `job.SaveJob(j)` |
|
||||
Logging is handled by `kun/log` package for standard application logging.
|
||||
| List Execs | `job.ListExecutions(param, page, pagesize)` |
|
||||
| Get Exec | `job.GetExecution(execID, param)` |
|
||||
| Save Exec | `job.SaveExecution(exec)` |
|
||||
|
|
@ -1247,78 +1228,49 @@ type RobotState struct {
|
|||
}
|
||||
```
|
||||
|
||||
### 8.3 Execution (Uses Job System)
|
||||
### 8.3 Execution (Uses ExecutionStore)
|
||||
|
||||
No separate `autonomous_executions` table. Uses existing Job system.
|
||||
Uses dedicated `__yao.agent_execution` table via ExecutionStore.
|
||||
|
||||
**Each trigger creates a new job.Job:**
|
||||
**Each trigger creates a new Execution:**
|
||||
|
||||
```go
|
||||
// On each trigger (clock/human/event), create a new Job
|
||||
execID := gonanoid.Must()
|
||||
j, _ := job.Once(job.GOROUTINE, map[string]interface{}{
|
||||
"job_id": "robot_exec_" + execID, // unique per execution
|
||||
"category_id": "autonomous_robot",
|
||||
"name": fmt.Sprintf("%s - %s", member.DisplayName, triggerType),
|
||||
"metadata": map[string]interface{}{
|
||||
"member_id": memberID,
|
||||
"team_id": teamID,
|
||||
"trigger_type": triggerType,
|
||||
"exec_id": execID,
|
||||
},
|
||||
})
|
||||
job.SaveJob(j)
|
||||
|
||||
// Configure and start
|
||||
j.ExecutionConfig = &job.ExecutionConfig{
|
||||
Type: job.ExecutionTypeProcess,
|
||||
ProcessName: "robot.Execute",
|
||||
ProcessArgs: []interface{}{memberID, execID, triggerData},
|
||||
// On each trigger (clock/human/event), create a new Execution
|
||||
exec := &types.Execution{
|
||||
ID: utils.NewID(),
|
||||
MemberID: memberID,
|
||||
TeamID: teamID,
|
||||
TriggerType: triggerType,
|
||||
Status: types.ExecStatusRunning,
|
||||
Phase: types.PhaseP0Init,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
j.Push()
|
||||
|
||||
// Save to ExecutionStore
|
||||
execStore.Save(exec)
|
||||
```
|
||||
|
||||
**Query executions for a robot:**
|
||||
|
||||
```go
|
||||
// List all executions for a robot member
|
||||
param := model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "category_id", Value: "autonomous_robot"},
|
||||
{Column: "metadata->member_id", Value: memberID},
|
||||
},
|
||||
Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}},
|
||||
}
|
||||
jobs, _ := job.ListJobs(param, 1, 10)
|
||||
executions, err := execStore.List(memberID, 1, 10)
|
||||
```
|
||||
|
||||
**Query examples:**
|
||||
|
||||
```go
|
||||
// List all robot jobs (all robots, all executions)
|
||||
param := model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "category_id", Value: "autonomous_robot"},
|
||||
},
|
||||
}
|
||||
jobs, _ := job.ListJobs(param, 1, 20)
|
||||
// Get execution by ID
|
||||
exec, err := execStore.Get(executionID)
|
||||
|
||||
// Get executions for a robot
|
||||
execParam := model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "job_id", Value: "robot_" + memberID},
|
||||
},
|
||||
Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}},
|
||||
}
|
||||
execs, _ := job.ListExecutions(execParam, 1, 10)
|
||||
// List executions for a robot
|
||||
executions, err := execStore.List(memberID, page, pageSize)
|
||||
|
||||
// Get logs for an execution
|
||||
logParam := model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "execution_id", Value: execID},
|
||||
},
|
||||
}
|
||||
logs, _ := job.ListLogs(logParam, 1, 100)
|
||||
// Update execution status
|
||||
execStore.UpdateStatus(executionID, types.ExecStatusCompleted)
|
||||
|
||||
// Logging via kun/log
|
||||
log.With(log.F{"execution_id": exec.ID, "phase": "P1"}).Info("Phase started")
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -82,11 +82,6 @@ yao/agent/robot/
|
|||
│ ├── db.go # Database queries
|
||||
│ └── learning.go # Learning entry save (to KB)
|
||||
│
|
||||
├── job/ # Job system integration
|
||||
│ ├── job.go # Create/Get job for robot
|
||||
│ ├── execution.go # Create/Update execution
|
||||
│ └── log.go # Write execution logs
|
||||
│
|
||||
└── plan/ # Plan queue (deferred tasks)
|
||||
├── plan.go # Plan queue struct
|
||||
└── schedule.go # Schedule for later
|
||||
|
|
@ -111,7 +106,7 @@ yao/assert/ # Universal assertion library (global package)
|
|||
│ │ │ │ │ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||
┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐┌─────────┐
|
||||
│ cache ││ dedup ││ store ││ pool ││job ││ plan ││ utils ││ trigger │
|
||||
│ cache ││ dedup ││ store ││ pool ││ plan ││ utils ││ trigger │
|
||||
└───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘└────┬────┘
|
||||
│ │ │ │ │ │
|
||||
└────────┴────────┴───────┴───────┴────────────────────────┘
|
||||
|
|
@ -145,9 +140,8 @@ yao/assert/ # Universal assertion library (global package)
|
|||
| `store/` | `types/` |
|
||||
| `pool/` | `types/` |
|
||||
| `trigger/` | `types/` |
|
||||
| `job/` | `types/`, `yao/job` |
|
||||
| `plan/` | `types/` |
|
||||
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/`, `yao/assert` |
|
||||
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `yao/assert` |
|
||||
| `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
|
||||
| | Manager handles all trigger logic (clock, intervene, event) |
|
||||
| `api/` | `types/`, `manager/` |
|
||||
|
|
@ -304,7 +298,6 @@ type TriggerResult struct {
|
|||
Accepted bool `json:"accepted"` // whether trigger was accepted
|
||||
Queued bool `json:"queued"` // true if queued (quota full)
|
||||
Execution *types.Execution `json:"execution,omitempty"` // execution info if started
|
||||
JobID string `json:"job_id,omitempty"` // job ID for tracking
|
||||
Message string `json:"message,omitempty"` // status message
|
||||
}
|
||||
|
||||
|
|
@ -524,7 +517,6 @@ interface TriggerResult {
|
|||
accepted: boolean;
|
||||
queued: boolean;
|
||||
execution?: Execution;
|
||||
job_id?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
|
|
@ -1160,7 +1152,7 @@ import (
|
|||
|
||||
// Robot - runtime representation of an autonomous robot (from __yao.member)
|
||||
// Relationship: 1 Robot : N Executions (concurrent)
|
||||
// Each trigger creates a new Execution (mapped to job.Job)
|
||||
// Each trigger creates a new Execution (stored in ExecutionStore)
|
||||
type Robot struct {
|
||||
// From __yao.member
|
||||
MemberID string `json:"member_id"`
|
||||
|
|
@ -1234,8 +1226,7 @@ func (r *Robot) GetExecutions() []*Execution {
|
|||
}
|
||||
|
||||
// Execution - single execution instance
|
||||
// Each trigger creates a new Execution, mapped to a job.Job for monitoring
|
||||
// Relationship: 1 Execution = 1 job.Job
|
||||
// Each trigger creates a new Execution, stored in ExecutionStore
|
||||
type Execution struct {
|
||||
ID string `json:"id"` // unique execution ID
|
||||
MemberID string `json:"member_id"` // robot member ID (globally unique)
|
||||
|
|
@ -1247,8 +1238,6 @@ type Execution struct {
|
|||
Phase Phase `json:"phase"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// Job integration (each Execution = 1 job.Job)
|
||||
JobID string `json:"job_id"` // corresponding job.Job ID
|
||||
|
||||
// Trigger input (stored for traceability)
|
||||
Input *TriggerInput `json:"input,omitempty"` // original trigger input
|
||||
|
|
@ -2392,7 +2381,6 @@ type ExecutionRecord struct {
|
|||
ExecutionID string `json:"execution_id"` // Unique execution identifier
|
||||
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||
TeamID string `json:"team_id"` // Team ID
|
||||
JobID string `json:"job_id,omitempty"` // Linked job.Job ID
|
||||
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
||||
|
||||
// Status tracking (synced with runtime Execution)
|
||||
|
|
|
|||
|
|
@ -165,7 +165,6 @@ Create empty structs and stub methods that return nil/empty/success:
|
|||
- [x] `dedup/dedup.go` - Dedup struct, stub methods
|
||||
- [x] `store/store.go` - Store struct, stub methods
|
||||
- [x] `pool/pool.go` - Pool struct, stub methods
|
||||
- [x] `job/job.go` - job helper stubs
|
||||
- [x] `plan/plan.go` - Plan struct, stub methods
|
||||
- [x] `trigger/trigger.go` - trigger dispatcher stub
|
||||
- [x] `executor/executor.go` - Executor struct, stub `Execute()`
|
||||
|
|
@ -278,35 +277,16 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
|
|||
- [x] ExecutionController lifecycle tests
|
||||
- [x] Manager integration tests for Intervene/HandleEvent
|
||||
|
||||
### ✅ 3.5 Job Integration (COMPLETE)
|
||||
### ✅ 3.5 Execution Storage (COMPLETE)
|
||||
|
||||
- [x] `job/job.go` - create job
|
||||
- [x] `job_id`: `robot_exec_{execID}`
|
||||
- [x] `category_name`: `Autonomous Robot` / `自主机器人` (localized)
|
||||
- [x] Metadata: member_id, team_id, trigger_type, exec_id, display_name
|
||||
- [x] `Options` struct for extensibility (Priority, MaxRetryCount, DefaultTimeout, Metadata)
|
||||
- [x] `Create()`, `Get()`, `Update()`, `Complete()`, `Fail()`, `Cancel()`
|
||||
- [x] Status mapping: ExecPending→queued, ExecRunning→running, etc.
|
||||
- [x] ExecutionStore - execution record persistence
|
||||
- [x] Execution data stored in `__yao.agent_execution` table
|
||||
- [x] All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning)
|
||||
- [x] Status and phase tracking
|
||||
- [x] Logging via `kun/log` package
|
||||
- [x] Localization support (en-US, zh-CN)
|
||||
- [x] `job/execution.go` - execution lifecycle
|
||||
- [x] `CreateOptions` struct for extensibility
|
||||
- [x] `CreateExecution()` - create both robot Execution and job.Execution
|
||||
- [x] `UpdatePhase()` - update phase with progress tracking (10%→25%→40%→60%→80%→95%)
|
||||
- [x] `UpdateStatus()` - update execution status
|
||||
- [x] `CompleteExecution()` / `FailExecution()` / `CancelExecution()`
|
||||
- [x] TriggerType → TriggerCategory mapping (clock→scheduled, human→manual, event→event)
|
||||
- [x] Duration calculation on completion/failure/cancellation
|
||||
- [x] `job/log.go` - write phase logs
|
||||
- [x] `Log()` - base log function with context
|
||||
- [x] `LogPhaseStart()` / `LogPhaseEnd()` / `LogPhaseError()`
|
||||
- [x] `LogError()` / `LogInfo()` / `LogDebug()` / `LogWarn()`
|
||||
- [x] `LogTaskStart()` / `LogTaskEnd()`
|
||||
- [x] `LogDelivery()` / `LogLearning()`
|
||||
- [x] Localization support for all log messages
|
||||
- [x] Test: job creation, execution tracking, log writing
|
||||
- [x] `job/job_test.go` - 17 test cases
|
||||
- [x] `job/execution_test.go` - 26 test cases
|
||||
- [x] `job/log_test.go` - 24 test cases
|
||||
- [x] Test: execution storage, status tracking
|
||||
- [x] `store/execution_test.go` - execution store tests
|
||||
- [x] All tests passing with real database
|
||||
|
||||
### ✅ 3.6 Executor Architecture (COMPLETE)
|
||||
|
|
@ -875,7 +855,7 @@ Created new `yao/assert` package for universal assertion/validation:
|
|||
|
||||
- [x] `yao/models/agent/execution.mod.yao` - Execution record model (`agent_execution` table)
|
||||
- [x] id, execution_id (unique)
|
||||
- [x] member_id (globally unique), team_id, job_id
|
||||
- [x] member_id (globally unique), team_id
|
||||
- [x] trigger_type (enum: clock, human, event)
|
||||
- [x] **Status tracking** (synced with runtime Execution):
|
||||
- [x] status (enum: pending, running, completed, failed, cancelled)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ result, _ := api.Trigger(ctx, "member_123", &api.TriggerRequest{
|
|||
})
|
||||
|
||||
// Check status
|
||||
exec, _ := api.GetExecution(ctx, result.JobID)
|
||||
exec, _ := api.GetExecution(ctx, result.ExecutionID)
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
|
@ -148,11 +148,11 @@ type TriggerRequest struct {
|
|||
|
||||
```go
|
||||
type TriggerResult struct {
|
||||
Accepted bool // Whether trigger was accepted
|
||||
Queued bool // Whether queued (vs immediate)
|
||||
Execution *types.Execution // Execution details
|
||||
JobID string // Execution ID for tracking
|
||||
Message string // Status message
|
||||
Accepted bool // Whether trigger was accepted
|
||||
Queued bool // Whether queued (vs immediate)
|
||||
Execution *types.Execution // Execution details
|
||||
ExecutionID string // Execution ID for tracking
|
||||
Message string // Status message
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ func TestAPIFullLifecycle(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.NotNil(t, triggerResult)
|
||||
assert.True(t, triggerResult.Accepted)
|
||||
assert.NotEmpty(t, triggerResult.JobID)
|
||||
assert.NotEmpty(t, triggerResult.ExecutionID)
|
||||
|
||||
// 7. Wait for execution to complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
|
@ -205,6 +205,75 @@ func TestAPIRobotQueryWithData(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestListRobotsAutonomousModeFilter tests the autonomous_mode filter
|
||||
func TestListRobotsAutonomousModeFilter(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupAPITestRobots(t)
|
||||
defer cleanupAPITestRobots(t)
|
||||
|
||||
// Setup: Create robots with different autonomous_mode settings
|
||||
setupAPITestRobotWithMode(t, "robot_api_auto_001", "team_api_mode", true) // autonomous
|
||||
setupAPITestRobotWithMode(t, "robot_api_auto_002", "team_api_mode", true) // autonomous
|
||||
setupAPITestRobotWithMode(t, "robot_api_demand_001", "team_api_mode", false) // on-demand
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("ListRobots returns all robots when autonomous_mode is nil", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_mode",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should have all 3 robots
|
||||
assert.Equal(t, 3, result.Total)
|
||||
})
|
||||
|
||||
t.Run("ListRobots filters by autonomous_mode=true", func(t *testing.T) {
|
||||
autonomousMode := true
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_mode",
|
||||
AutonomousMode: &autonomousMode,
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should have only 2 autonomous robots
|
||||
assert.Equal(t, 2, result.Total)
|
||||
for _, robot := range result.Data {
|
||||
assert.True(t, robot.AutonomousMode, "All returned robots should be autonomous")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListRobots filters by autonomous_mode=false", func(t *testing.T) {
|
||||
autonomousMode := false
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_mode",
|
||||
AutonomousMode: &autonomousMode,
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should have only 1 on-demand robot
|
||||
assert.Equal(t, 1, result.Total)
|
||||
for _, robot := range result.Data {
|
||||
assert.False(t, robot.AutonomousMode, "All returned robots should be on-demand")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIExecutionQueryWithData tests execution query APIs with real data
|
||||
func TestAPIExecutionQueryWithData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
|
|
@ -333,7 +402,7 @@ func TestAPITriggerWithData(t *testing.T) {
|
|||
require.NotNil(t, result)
|
||||
|
||||
assert.True(t, result.Accepted)
|
||||
assert.NotEmpty(t, result.JobID)
|
||||
assert.NotEmpty(t, result.ExecutionID)
|
||||
assert.Contains(t, result.Message, "submitted")
|
||||
})
|
||||
|
||||
|
|
@ -377,6 +446,44 @@ func TestAPITriggerWithData(t *testing.T) {
|
|||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
// setupAPITestRobotWithMode creates a test robot with specific autonomous_mode setting
|
||||
func setupAPITestRobotWithMode(t *testing.T, memberID, teamID string, autonomousMode bool) {
|
||||
m := model.Select("__yao.member")
|
||||
tableName := m.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
robotConfig := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "API Test Robot",
|
||||
"duties": []string{"Testing API functions"},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 5,
|
||||
"queue": 20,
|
||||
"priority": 5,
|
||||
},
|
||||
}
|
||||
configJSON, _ := json.Marshal(robotConfig)
|
||||
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": memberID,
|
||||
"team_id": teamID,
|
||||
"member_type": "robot",
|
||||
"display_name": "API Test Robot " + memberID,
|
||||
"system_prompt": "You are an API test robot.",
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": autonomousMode,
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configJSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot %s: %v", memberID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// setupAPITestRobot creates a test robot in the database
|
||||
func setupAPITestRobot(t *testing.T, memberID, teamID string) {
|
||||
m := model.Select("__yao.member")
|
||||
|
|
@ -435,7 +542,6 @@ func setupAPITestExecution(t *testing.T, execID, memberID string, triggerType ty
|
|||
ExecutionID: execID,
|
||||
MemberID: memberID,
|
||||
TeamID: "team_api_exec",
|
||||
JobID: "job_" + execID,
|
||||
TriggerType: triggerType,
|
||||
Status: status,
|
||||
Phase: types.PhaseDelivery,
|
||||
|
|
@ -462,11 +568,17 @@ func cleanupAPITestRobots(t *testing.T) {
|
|||
tableName := m.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
// Delete all robots with member_id starting with "robot_api_"
|
||||
// Delete all robots with member_id starting with "robot_api_" or "api_robot_"
|
||||
_, err := qb.Table(tableName).Where("member_id", "like", "robot_api_%").Delete()
|
||||
if err != nil {
|
||||
t.Logf("Warning: cleanup robots error: %v", err)
|
||||
}
|
||||
|
||||
// Also delete "api_robot_" prefixed robots (new tests)
|
||||
_, err = qb.Table(tableName).Where("member_id", "like", "api_robot_%").Delete()
|
||||
if err != nil {
|
||||
t.Logf("Warning: cleanup robots error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupAPITestExecutions removes all API test executions
|
||||
|
|
|
|||
|
|
@ -71,9 +71,9 @@ func TestE2EClockTriggerFullFlow(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Accepted, "Clock trigger should be accepted: %s", result.Message)
|
||||
assert.NotEmpty(t, result.JobID, "Should return job ID")
|
||||
assert.NotEmpty(t, result.ExecutionID, "Should return execution ID")
|
||||
|
||||
t.Logf("Execution started: JobID=%s", result.JobID)
|
||||
t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
|
||||
|
||||
// Wait for execution to complete (real LLM calls take time)
|
||||
// P0→P4 typically takes 30-60 seconds with real LLM
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ func TestE2EConcurrentMultipleRobots(t *testing.T) {
|
|||
|
||||
if result.Accepted {
|
||||
acceptedCount.Add(1)
|
||||
t.Logf("Robot %s accepted: JobID=%s", id, result.JobID)
|
||||
t.Logf("Robot %s accepted: ExecutionID=%s", id, result.ExecutionID)
|
||||
}
|
||||
}(i, memberID)
|
||||
}
|
||||
|
|
@ -176,7 +176,7 @@ func TestE2EConcurrentSameRobotMultipleTriggers(t *testing.T) {
|
|||
|
||||
if result.Accepted {
|
||||
acceptedCount.Add(1)
|
||||
t.Logf("Trigger %d accepted: JobID=%s", idx, result.JobID)
|
||||
t.Logf("Trigger %d accepted: ExecutionID=%s", idx, result.ExecutionID)
|
||||
} else {
|
||||
t.Logf("Trigger %d rejected: %s", idx, result.Message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ func TestE2EControlPauseResume(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.True(t, result.Accepted)
|
||||
|
||||
t.Logf("Execution started: JobID=%s", result.JobID)
|
||||
t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
|
||||
|
||||
// Wait for execution to start running
|
||||
var execID string
|
||||
|
|
@ -166,7 +166,7 @@ func TestE2EControlStop(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.True(t, result.Accepted)
|
||||
|
||||
t.Logf("Execution started: JobID=%s", result.JobID)
|
||||
t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
|
||||
|
||||
// Wait for execution to start running
|
||||
var execID string
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ func TestE2EEventTriggerFullFlow(t *testing.T) {
|
|||
require.NotNil(t, result)
|
||||
assert.True(t, result.Accepted, "Event trigger should be accepted")
|
||||
|
||||
t.Logf("Event trigger result: Accepted=%v, JobID=%s", result.Accepted, result.JobID)
|
||||
t.Logf("Event trigger result: Accepted=%v, ExecutionID=%s", result.Accepted, result.ExecutionID)
|
||||
|
||||
// Wait for execution to complete
|
||||
var exec *types.Execution
|
||||
|
|
@ -256,7 +256,7 @@ func TestE2EEventTriggerVariousEventTypes(t *testing.T) {
|
|||
require.NotNil(t, result)
|
||||
assert.True(t, result.Accepted, "Event should be accepted")
|
||||
|
||||
t.Logf("Event triggered: JobID=%s", result.JobID)
|
||||
t.Logf("Event triggered: ExecutionID=%s", result.ExecutionID)
|
||||
|
||||
// Wait for execution
|
||||
maxWait := 120 * time.Second
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
|
|
@ -14,6 +18,9 @@ import (
|
|||
// memberModel is the model name for member table
|
||||
const memberModel = "__yao.member"
|
||||
|
||||
// robotStore is the shared robot store instance
|
||||
var robotStore = store.NewRobotStore()
|
||||
|
||||
// GetRobot returns a robot by member ID
|
||||
// Returns the robot from cache if available, otherwise loads from database
|
||||
func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) {
|
||||
|
|
@ -77,15 +84,25 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Get permission fields from store (for access control)
|
||||
record, _ := robotStore.Get(context.Background(), memberID)
|
||||
|
||||
state := &RobotState{
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
DisplayName: robot.DisplayName,
|
||||
Bio: robot.Bio,
|
||||
Status: robot.Status,
|
||||
Running: robot.RunningCount(),
|
||||
MaxRunning: 2, // default
|
||||
}
|
||||
|
||||
// Add permission fields if available
|
||||
if record != nil {
|
||||
state.YaoCreatedBy = record.YaoCreatedBy
|
||||
state.YaoTeamID = record.YaoTeamID
|
||||
}
|
||||
|
||||
if robot.Config != nil && robot.Config.Quota != nil {
|
||||
state.MaxRunning = robot.Config.Quota.GetMax()
|
||||
}
|
||||
|
|
@ -121,7 +138,7 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) {
|
|||
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{
|
||||
"id", "member_id", "team_id", "display_name",
|
||||
"id", "member_id", "team_id", "display_name", "bio",
|
||||
"system_prompt", "robot_status", "autonomous_mode",
|
||||
"robot_config", "robot_email",
|
||||
},
|
||||
|
|
@ -152,7 +169,6 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
|
|||
// Build where conditions
|
||||
wheres := []model.QueryWhere{
|
||||
{Column: "member_type", Value: "robot"},
|
||||
{Column: "autonomous_mode", Value: true},
|
||||
{Column: "status", Value: "active"},
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +185,9 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
|
|||
Value: "%" + query.Keywords + "%",
|
||||
})
|
||||
}
|
||||
if query.AutonomousMode != nil {
|
||||
wheres = append(wheres, model.QueryWhere{Column: "autonomous_mode", Value: *query.AutonomousMode})
|
||||
}
|
||||
|
||||
// Build order
|
||||
orders := []model.QueryOrder{}
|
||||
|
|
@ -181,7 +200,7 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
|
|||
// Execute paginated query
|
||||
result, err := m.Paginate(model.QueryParam{
|
||||
Select: []interface{}{
|
||||
"id", "member_id", "team_id", "display_name",
|
||||
"id", "member_id", "team_id", "display_name", "bio",
|
||||
"system_prompt", "robot_status", "autonomous_mode",
|
||||
"robot_config", "robot_email",
|
||||
},
|
||||
|
|
@ -256,3 +275,375 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult {
|
|||
PageSize: query.PageSize,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Robot CRUD API ====================
|
||||
// These functions create, update, and delete robots
|
||||
// They call store layer for persistence and manage cache
|
||||
// Request/Response types are defined in types.go
|
||||
|
||||
// CreateRobot creates a new robot member
|
||||
// Calls store.RobotStore.Save() and refreshes cache
|
||||
// If member_id is not provided, it will be auto-generated
|
||||
func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, error) {
|
||||
// Validate required fields
|
||||
if req.TeamID == "" {
|
||||
return nil, fmt.Errorf("team_id is required")
|
||||
}
|
||||
if req.DisplayName == "" {
|
||||
return nil, fmt.Errorf("display_name is required")
|
||||
}
|
||||
|
||||
// Generate member_id if not provided
|
||||
if req.MemberID == "" {
|
||||
generatedID, err := generateMemberID(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate member_id: %w", err)
|
||||
}
|
||||
req.MemberID = generatedID
|
||||
}
|
||||
|
||||
// Check if robot already exists
|
||||
existing, err := robotStore.Get(context.Background(), req.MemberID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check existing robot: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, fmt.Errorf("robot with member_id '%s' already exists", req.MemberID)
|
||||
}
|
||||
|
||||
// Determine autonomous_mode value
|
||||
autonomousMode := false
|
||||
if req.AutonomousMode != nil {
|
||||
autonomousMode = *req.AutonomousMode
|
||||
}
|
||||
|
||||
// Determine status values
|
||||
status := "active"
|
||||
if req.Status != "" {
|
||||
status = req.Status
|
||||
}
|
||||
robotStatus := "idle"
|
||||
if req.RobotStatus != "" {
|
||||
robotStatus = req.RobotStatus
|
||||
}
|
||||
|
||||
// Create store record with all fields
|
||||
now := time.Now()
|
||||
record := &store.RobotRecord{
|
||||
// Required
|
||||
MemberID: req.MemberID,
|
||||
TeamID: req.TeamID,
|
||||
MemberType: "robot",
|
||||
Status: status,
|
||||
RobotStatus: robotStatus,
|
||||
AutonomousMode: autonomousMode,
|
||||
|
||||
// Profile
|
||||
DisplayName: req.DisplayName,
|
||||
Bio: req.Bio,
|
||||
Avatar: req.Avatar,
|
||||
|
||||
// Identity & Role
|
||||
SystemPrompt: req.SystemPrompt,
|
||||
RoleID: req.RoleID,
|
||||
ManagerID: req.ManagerID,
|
||||
|
||||
// Communication
|
||||
RobotEmail: req.RobotEmail,
|
||||
AuthorizedSenders: req.AuthorizedSenders,
|
||||
EmailFilterRules: req.EmailFilterRules,
|
||||
|
||||
// Capabilities
|
||||
RobotConfig: req.RobotConfig,
|
||||
Agents: req.Agents,
|
||||
MCPServers: req.MCPServers,
|
||||
LanguageModel: req.LanguageModel,
|
||||
|
||||
// Limits
|
||||
CostLimit: req.CostLimit,
|
||||
|
||||
// Timestamps
|
||||
JoinedAt: &now,
|
||||
}
|
||||
|
||||
// Apply Yao permission fields if provided
|
||||
if req.AuthScope != nil {
|
||||
record.YaoCreatedBy = req.AuthScope.CreatedBy
|
||||
record.YaoTeamID = req.AuthScope.TeamID
|
||||
record.YaoTenantID = req.AuthScope.TenantID
|
||||
// Set invited_by from CreatedBy if not explicitly set
|
||||
if record.InvitedBy == "" && req.AuthScope.CreatedBy != "" {
|
||||
record.InvitedBy = req.AuthScope.CreatedBy
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
err = robotStore.Save(context.Background(), record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create robot: %w", err)
|
||||
}
|
||||
|
||||
// Refresh cache if manager is running
|
||||
// Use Refresh() which handles autonomous_mode correctly:
|
||||
// - If autonomous_mode=true: adds to cache for scheduling
|
||||
// - If autonomous_mode=false: does not add to cache
|
||||
mgr, err := getManager()
|
||||
if err == nil && mgr != nil {
|
||||
_ = mgr.Cache().Refresh(ctx, req.MemberID)
|
||||
}
|
||||
|
||||
// Return the created robot as response
|
||||
return GetRobotResponse(ctx, req.MemberID)
|
||||
}
|
||||
|
||||
// UpdateRobot updates an existing robot member
|
||||
// Calls store.RobotStore.Save() and refreshes cache
|
||||
func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (*RobotResponse, error) {
|
||||
if memberID == "" {
|
||||
return nil, fmt.Errorf("member_id is required")
|
||||
}
|
||||
|
||||
// Get existing record
|
||||
existing, err := robotStore.Get(context.Background(), memberID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get robot: %w", err)
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// Apply updates - only non-nil fields are updated
|
||||
// Profile
|
||||
if req.DisplayName != nil {
|
||||
existing.DisplayName = *req.DisplayName
|
||||
}
|
||||
if req.Bio != nil {
|
||||
existing.Bio = *req.Bio
|
||||
}
|
||||
if req.Avatar != nil {
|
||||
existing.Avatar = *req.Avatar
|
||||
}
|
||||
|
||||
// Identity & Role
|
||||
if req.SystemPrompt != nil {
|
||||
existing.SystemPrompt = *req.SystemPrompt
|
||||
}
|
||||
if req.RoleID != nil {
|
||||
existing.RoleID = *req.RoleID
|
||||
}
|
||||
if req.ManagerID != nil {
|
||||
existing.ManagerID = *req.ManagerID
|
||||
}
|
||||
|
||||
// Status
|
||||
if req.Status != nil {
|
||||
existing.Status = *req.Status
|
||||
}
|
||||
if req.RobotStatus != nil {
|
||||
existing.RobotStatus = *req.RobotStatus
|
||||
}
|
||||
if req.AutonomousMode != nil {
|
||||
existing.AutonomousMode = *req.AutonomousMode
|
||||
}
|
||||
|
||||
// Communication
|
||||
if req.RobotEmail != nil {
|
||||
existing.RobotEmail = *req.RobotEmail
|
||||
}
|
||||
if req.AuthorizedSenders != nil {
|
||||
existing.AuthorizedSenders = req.AuthorizedSenders
|
||||
}
|
||||
if req.EmailFilterRules != nil {
|
||||
existing.EmailFilterRules = req.EmailFilterRules
|
||||
}
|
||||
|
||||
// Capabilities
|
||||
if req.RobotConfig != nil {
|
||||
existing.RobotConfig = req.RobotConfig
|
||||
}
|
||||
if req.Agents != nil {
|
||||
existing.Agents = req.Agents
|
||||
}
|
||||
if req.MCPServers != nil {
|
||||
existing.MCPServers = req.MCPServers
|
||||
}
|
||||
if req.LanguageModel != nil {
|
||||
existing.LanguageModel = *req.LanguageModel
|
||||
}
|
||||
|
||||
// Limits
|
||||
if req.CostLimit != nil {
|
||||
existing.CostLimit = *req.CostLimit
|
||||
}
|
||||
|
||||
// Apply Yao permission fields if provided (update scope)
|
||||
if req.AuthScope != nil {
|
||||
existing.YaoUpdatedBy = req.AuthScope.UpdatedBy
|
||||
// Team and Tenant are typically set on create, not update
|
||||
// But allow override if explicitly provided
|
||||
if req.AuthScope.TeamID != "" {
|
||||
existing.YaoTeamID = req.AuthScope.TeamID
|
||||
}
|
||||
if req.AuthScope.TenantID != "" {
|
||||
existing.YaoTenantID = req.AuthScope.TenantID
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
err = robotStore.Save(context.Background(), existing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update robot: %w", err)
|
||||
}
|
||||
|
||||
// Refresh cache if manager is running
|
||||
// Use Refresh() which handles autonomous_mode correctly:
|
||||
// - If autonomous_mode=true: adds to cache for scheduling
|
||||
// - If autonomous_mode=false: removes from cache
|
||||
mgr, err := getManager()
|
||||
if err == nil && mgr != nil {
|
||||
_ = mgr.Cache().Refresh(ctx, memberID) // Ignore error, database is already saved
|
||||
}
|
||||
|
||||
// Return the updated robot as response
|
||||
return GetRobotResponse(ctx, memberID)
|
||||
}
|
||||
|
||||
// RemoveRobot deletes a robot member
|
||||
// Calls store.RobotStore.Delete() and invalidates cache
|
||||
func RemoveRobot(ctx *types.Context, memberID string) error {
|
||||
if memberID == "" {
|
||||
return fmt.Errorf("member_id is required")
|
||||
}
|
||||
|
||||
// Check if robot exists
|
||||
existing, err := robotStore.Get(context.Background(), memberID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get robot: %w", err)
|
||||
}
|
||||
if existing == nil {
|
||||
return types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// Check if robot has running executions
|
||||
mgr, err := getManager()
|
||||
if err == nil && mgr != nil {
|
||||
robot := mgr.Cache().Get(memberID)
|
||||
if robot != nil && robot.RunningCount() > 0 {
|
||||
return fmt.Errorf("cannot delete robot with running executions")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
err = robotStore.Delete(context.Background(), memberID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete robot: %w", err)
|
||||
}
|
||||
|
||||
// Invalidate cache if manager is running
|
||||
if mgr != nil {
|
||||
mgr.Cache().Remove(memberID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRobotResponse retrieves a robot and converts to API response format
|
||||
func GetRobotResponse(ctx *types.Context, memberID string) (*RobotResponse, error) {
|
||||
record, err := robotStore.Get(context.Background(), memberID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get robot: %w", err)
|
||||
}
|
||||
if record == nil {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
return recordToResponse(record), nil
|
||||
}
|
||||
|
||||
// recordToResponse converts a store.RobotRecord to API RobotResponse
|
||||
func recordToResponse(record *store.RobotRecord) *RobotResponse {
|
||||
return &RobotResponse{
|
||||
ID: record.ID,
|
||||
MemberID: record.MemberID,
|
||||
TeamID: record.TeamID,
|
||||
Status: record.Status,
|
||||
RobotStatus: record.RobotStatus,
|
||||
AutonomousMode: record.AutonomousMode,
|
||||
|
||||
DisplayName: record.DisplayName,
|
||||
Bio: record.Bio,
|
||||
Avatar: record.Avatar,
|
||||
|
||||
SystemPrompt: record.SystemPrompt,
|
||||
RoleID: record.RoleID,
|
||||
ManagerID: record.ManagerID,
|
||||
|
||||
RobotEmail: record.RobotEmail,
|
||||
AuthorizedSenders: record.AuthorizedSenders,
|
||||
EmailFilterRules: record.EmailFilterRules,
|
||||
|
||||
RobotConfig: record.RobotConfig,
|
||||
Agents: record.Agents,
|
||||
MCPServers: record.MCPServers,
|
||||
LanguageModel: record.LanguageModel,
|
||||
|
||||
CostLimit: record.CostLimit,
|
||||
InvitedBy: record.InvitedBy,
|
||||
JoinedAt: record.JoinedAt,
|
||||
YaoCreatedBy: record.YaoCreatedBy,
|
||||
YaoTeamID: record.YaoTeamID,
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Member ID Generation ====================
|
||||
|
||||
// generateMemberID generates a unique member_id with collision detection
|
||||
// Uses 12-digit numeric ID to match existing pattern in openapi/oauth/providers/user
|
||||
func generateMemberID(ctx context.Context) (string, error) {
|
||||
const maxRetries = 10
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
// Generate 12-digit numeric ID
|
||||
id, err := gonanoid.Generate("0123456789", 12)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate member_id: %w", err)
|
||||
}
|
||||
|
||||
// Check if ID already exists
|
||||
exists, err := memberIDExists(ctx, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check member_id existence: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return id, nil
|
||||
}
|
||||
// ID exists, retry
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
// memberIDExists checks if a member_id already exists in the database
|
||||
func memberIDExists(ctx context.Context, memberID string) (bool, error) {
|
||||
m := model.Select(memberModel)
|
||||
if m == nil {
|
||||
return false, fmt.Errorf("model %s not found", memberModel)
|
||||
}
|
||||
|
||||
members, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"id"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: memberID},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(members) > 0, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
|
|
@ -100,3 +101,376 @@ func TestGetRobotStatusValidation(t *testing.T) {
|
|||
assert.Nil(t, status)
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Robot CRUD API Tests ====================
|
||||
|
||||
// TestCreateRobotValidation tests parameter validation for CreateRobot
|
||||
func TestCreateRobotValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("auto_generates_member_id_when_empty", func(t *testing.T) {
|
||||
req := &api.CreateRobotRequest{
|
||||
MemberID: "",
|
||||
TeamID: "team_001",
|
||||
DisplayName: "Test Robot Auto ID",
|
||||
}
|
||||
result, err := api.CreateRobot(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Verify member_id was auto-generated (12-digit numeric)
|
||||
assert.NotEmpty(t, result.MemberID)
|
||||
assert.Len(t, result.MemberID, 12, "Auto-generated member_id should be 12 digits")
|
||||
|
||||
// Cleanup
|
||||
_ = api.RemoveRobot(ctx, result.MemberID)
|
||||
})
|
||||
|
||||
t.Run("returns_error_for_empty_team_id", func(t *testing.T) {
|
||||
req := &api.CreateRobotRequest{
|
||||
MemberID: "robot_test_001",
|
||||
TeamID: "",
|
||||
DisplayName: "Test Robot",
|
||||
}
|
||||
result, err := api.CreateRobot(ctx, req)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "team_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns_error_for_empty_display_name", func(t *testing.T) {
|
||||
req := &api.CreateRobotRequest{
|
||||
MemberID: "robot_test_001",
|
||||
TeamID: "team_001",
|
||||
DisplayName: "",
|
||||
}
|
||||
result, err := api.CreateRobot(ctx, req)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "display_name is required")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateRobot tests the CreateRobot API function
|
||||
func TestCreateRobot(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Cleanup before and after
|
||||
cleanupAPITestRobots(t)
|
||||
defer cleanupAPITestRobots(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("creates_robot_with_required_fields", func(t *testing.T) {
|
||||
req := &api.CreateRobotRequest{
|
||||
MemberID: "api_robot_create_001",
|
||||
TeamID: "api_team_001",
|
||||
DisplayName: "API Test Robot",
|
||||
}
|
||||
|
||||
result, err := api.CreateRobot(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, "api_robot_create_001", result.MemberID)
|
||||
assert.Equal(t, "api_team_001", result.TeamID)
|
||||
assert.Equal(t, "API Test Robot", result.DisplayName)
|
||||
assert.Equal(t, "active", result.Status)
|
||||
assert.Equal(t, "idle", result.RobotStatus)
|
||||
})
|
||||
|
||||
t.Run("creates_robot_with_all_fields", func(t *testing.T) {
|
||||
autonomousMode := true
|
||||
req := &api.CreateRobotRequest{
|
||||
MemberID: "api_robot_create_002",
|
||||
TeamID: "api_team_002",
|
||||
DisplayName: "Full Robot",
|
||||
Bio: "A fully configured robot",
|
||||
SystemPrompt: "You are a helpful assistant",
|
||||
Avatar: "https://example.com/avatar.png",
|
||||
RoleID: "admin",
|
||||
ManagerID: "user_001",
|
||||
AutonomousMode: &autonomousMode,
|
||||
RobotEmail: "fullrobot@test.com",
|
||||
LanguageModel: "gpt-4",
|
||||
CostLimit: 100.0,
|
||||
RobotConfig: map[string]interface{}{
|
||||
"clock_mode": "on",
|
||||
"max_concurrent": 3,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := api.CreateRobot(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, "api_robot_create_002", result.MemberID)
|
||||
assert.Equal(t, "Full Robot", result.DisplayName)
|
||||
assert.Equal(t, "A fully configured robot", result.Bio)
|
||||
assert.Equal(t, "You are a helpful assistant", result.SystemPrompt)
|
||||
assert.Equal(t, "admin", result.RoleID)
|
||||
assert.True(t, result.AutonomousMode)
|
||||
assert.Equal(t, "fullrobot@test.com", result.RobotEmail)
|
||||
assert.Equal(t, "gpt-4", result.LanguageModel)
|
||||
assert.Equal(t, 100.0, result.CostLimit)
|
||||
})
|
||||
|
||||
t.Run("creates_robot_with_auth_scope", func(t *testing.T) {
|
||||
req := &api.CreateRobotRequest{
|
||||
MemberID: "api_robot_create_003",
|
||||
TeamID: "api_team_003",
|
||||
DisplayName: "Robot with Auth",
|
||||
AuthScope: &api.AuthScope{
|
||||
CreatedBy: "user_123",
|
||||
TeamID: "perm_team_001",
|
||||
TenantID: "tenant_001",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := api.CreateRobot(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, "api_robot_create_003", result.MemberID)
|
||||
// InvitedBy should be set from AuthScope.CreatedBy
|
||||
assert.Equal(t, "user_123", result.InvitedBy)
|
||||
})
|
||||
|
||||
t.Run("returns_error_for_duplicate_member_id", func(t *testing.T) {
|
||||
req := &api.CreateRobotRequest{
|
||||
MemberID: "api_robot_create_001", // Already created above
|
||||
TeamID: "api_team_001",
|
||||
DisplayName: "Duplicate Robot",
|
||||
}
|
||||
|
||||
result, err := api.CreateRobot(ctx, req)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateRobot tests the UpdateRobot API function
|
||||
func TestUpdateRobot(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupAPITestRobots(t)
|
||||
defer cleanupAPITestRobots(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Create a robot to update
|
||||
createReq := &api.CreateRobotRequest{
|
||||
MemberID: "api_robot_update_001",
|
||||
TeamID: "api_team_update",
|
||||
DisplayName: "Original Name",
|
||||
Bio: "Original bio",
|
||||
}
|
||||
_, err := api.CreateRobot(ctx, createReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("returns_error_for_empty_member_id", func(t *testing.T) {
|
||||
req := &api.UpdateRobotRequest{}
|
||||
result, err := api.UpdateRobot(ctx, "", req)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns_error_for_non_existent_robot", func(t *testing.T) {
|
||||
newName := "New Name"
|
||||
req := &api.UpdateRobotRequest{
|
||||
DisplayName: &newName,
|
||||
}
|
||||
result, err := api.UpdateRobot(ctx, "non_existent_robot", req)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("updates_display_name", func(t *testing.T) {
|
||||
newName := "Updated Name"
|
||||
req := &api.UpdateRobotRequest{
|
||||
DisplayName: &newName,
|
||||
}
|
||||
|
||||
result, err := api.UpdateRobot(ctx, "api_robot_update_001", req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, "Updated Name", result.DisplayName)
|
||||
// Bio should be unchanged
|
||||
assert.Equal(t, "Original bio", result.Bio)
|
||||
})
|
||||
|
||||
t.Run("updates_multiple_fields", func(t *testing.T) {
|
||||
newBio := "New bio description"
|
||||
newPrompt := "Updated system prompt"
|
||||
autonomousMode := true
|
||||
|
||||
req := &api.UpdateRobotRequest{
|
||||
Bio: &newBio,
|
||||
SystemPrompt: &newPrompt,
|
||||
AutonomousMode: &autonomousMode,
|
||||
}
|
||||
|
||||
result, err := api.UpdateRobot(ctx, "api_robot_update_001", req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, "New bio description", result.Bio)
|
||||
assert.Equal(t, "Updated system prompt", result.SystemPrompt)
|
||||
assert.True(t, result.AutonomousMode)
|
||||
})
|
||||
|
||||
t.Run("updates_robot_status", func(t *testing.T) {
|
||||
newStatus := "working"
|
||||
req := &api.UpdateRobotRequest{
|
||||
RobotStatus: &newStatus,
|
||||
}
|
||||
|
||||
result, err := api.UpdateRobot(ctx, "api_robot_update_001", req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, "working", result.RobotStatus)
|
||||
})
|
||||
|
||||
t.Run("updates_config", func(t *testing.T) {
|
||||
newConfig := map[string]interface{}{
|
||||
"clock_mode": "off",
|
||||
"max_concurrent": 5,
|
||||
}
|
||||
req := &api.UpdateRobotRequest{
|
||||
RobotConfig: newConfig,
|
||||
}
|
||||
|
||||
result, err := api.UpdateRobot(ctx, "api_robot_update_001", req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.NotNil(t, result.RobotConfig)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRemoveRobot tests the RemoveRobot API function
|
||||
func TestRemoveRobot(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupAPITestRobots(t)
|
||||
defer cleanupAPITestRobots(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("returns_error_for_empty_member_id", func(t *testing.T) {
|
||||
err := api.RemoveRobot(ctx, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "member_id is required")
|
||||
})
|
||||
|
||||
t.Run("returns_error_for_non_existent_robot", func(t *testing.T) {
|
||||
err := api.RemoveRobot(ctx, "non_existent_robot")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("removes_existing_robot", func(t *testing.T) {
|
||||
// Create a robot
|
||||
createReq := &api.CreateRobotRequest{
|
||||
MemberID: "api_robot_remove_001",
|
||||
TeamID: "api_team_remove",
|
||||
DisplayName: "Robot to Remove",
|
||||
}
|
||||
_, err := api.CreateRobot(ctx, createReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it exists
|
||||
robot, err := api.GetRobot(ctx, "api_robot_remove_001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, robot)
|
||||
|
||||
// Remove it
|
||||
err = api.RemoveRobot(ctx, "api_robot_remove_001")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
robot, err = api.GetRobot(ctx, "api_robot_remove_001")
|
||||
assert.Error(t, err) // Should return error for non-existent
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetRobotResponse tests the GetRobotResponse API function
|
||||
func TestGetRobotResponse(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupAPITestRobots(t)
|
||||
defer cleanupAPITestRobots(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Create a robot
|
||||
autonomousMode := true
|
||||
createReq := &api.CreateRobotRequest{
|
||||
MemberID: "api_robot_response_001",
|
||||
TeamID: "api_team_response",
|
||||
DisplayName: "Response Test Robot",
|
||||
Bio: "Test bio for response",
|
||||
SystemPrompt: "Test prompt",
|
||||
AutonomousMode: &autonomousMode,
|
||||
RobotEmail: "response@test.com",
|
||||
CostLimit: 50.0,
|
||||
}
|
||||
_, err := api.CreateRobot(ctx, createReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("returns_robot_response_format", func(t *testing.T) {
|
||||
result, err := api.GetRobotResponse(ctx, "api_robot_response_001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Verify all fields are present in response
|
||||
assert.Equal(t, "api_robot_response_001", result.MemberID)
|
||||
assert.Equal(t, "api_team_response", result.TeamID)
|
||||
assert.Equal(t, "Response Test Robot", result.DisplayName)
|
||||
assert.Equal(t, "Test bio for response", result.Bio)
|
||||
assert.Equal(t, "Test prompt", result.SystemPrompt)
|
||||
assert.True(t, result.AutonomousMode)
|
||||
assert.Equal(t, "response@test.com", result.RobotEmail)
|
||||
assert.Equal(t, 50.0, result.CostLimit)
|
||||
assert.Equal(t, "active", result.Status)
|
||||
assert.Equal(t, "idle", result.RobotStatus)
|
||||
})
|
||||
|
||||
t.Run("returns_error_for_non_existent", func(t *testing.T) {
|
||||
result, err := api.GetRobotResponse(ctx, "non_existent")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// Note: cleanupAPITestRobots is defined in api_test.go (shared helper)
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ func TriggerManual(ctx *types.Context, memberID string, triggerType types.Trigge
|
|||
}
|
||||
|
||||
return &TriggerResult{
|
||||
Accepted: true,
|
||||
JobID: execID,
|
||||
Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType),
|
||||
Accepted: true,
|
||||
ExecutionID: execID,
|
||||
Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -123,9 +123,9 @@ func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req
|
|||
}
|
||||
|
||||
return &TriggerResult{
|
||||
Accepted: true,
|
||||
JobID: result.ExecutionID,
|
||||
Message: result.Message,
|
||||
Accepted: true,
|
||||
ExecutionID: result.ExecutionID,
|
||||
Message: result.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -150,9 +150,9 @@ func triggerEvent(ctx *types.Context, mgr managerInterface, memberID string, req
|
|||
}
|
||||
|
||||
return &TriggerResult{
|
||||
Accepted: true,
|
||||
JobID: result.ExecutionID,
|
||||
Message: result.Message,
|
||||
Accepted: true,
|
||||
ExecutionID: result.ExecutionID,
|
||||
Message: result.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -173,9 +173,9 @@ func triggerManual(ctx *types.Context, mgr managerInterface, memberID string, re
|
|||
}
|
||||
|
||||
return &TriggerResult{
|
||||
Accepted: true,
|
||||
JobID: execID,
|
||||
Message: fmt.Sprintf("Trigger (%s) submitted", req.Type),
|
||||
Accepted: true,
|
||||
ExecutionID: execID,
|
||||
Message: fmt.Sprintf("Trigger (%s) submitted", req.Type),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import (
|
|||
|
||||
// ListQuery - query options for List()
|
||||
type ListQuery struct {
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
Status types.RobotStatus `json:"status,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ClockMode types.ClockMode `json:"clock_mode,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
Status types.RobotStatus `json:"status,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ClockMode types.ClockMode `json:"clock_mode,omitempty"`
|
||||
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // nil=all, true=autonomous only, false=on-demand only
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// ListResult - result of List()
|
||||
|
|
@ -28,15 +29,18 @@ type ListResult struct {
|
|||
|
||||
// RobotState - runtime state from Status()
|
||||
type RobotState struct {
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status types.RobotStatus `json:"status"`
|
||||
Running int `json:"running"`
|
||||
MaxRunning int `json:"max_running"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"`
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Status types.RobotStatus `json:"status"`
|
||||
Running int `json:"running"`
|
||||
MaxRunning int `json:"max_running"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"`
|
||||
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id for permission check
|
||||
YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for permission check
|
||||
}
|
||||
|
||||
// ==================== Trigger Types ====================
|
||||
|
|
@ -78,11 +82,11 @@ const (
|
|||
|
||||
// TriggerResult - result of Trigger()
|
||||
type TriggerResult struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Queued bool `json:"queued"`
|
||||
Execution *types.Execution `json:"execution,omitempty"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Accepted bool `json:"accepted"`
|
||||
Queued bool `json:"queued"`
|
||||
Execution *types.Execution `json:"execution,omitempty"`
|
||||
ExecutionID string `json:"execution_id,omitempty"` // Execution ID
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Execution Types ====================
|
||||
|
|
@ -103,6 +107,136 @@ type ExecutionResult struct {
|
|||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
|
||||
// ==================== CRUD Types ====================
|
||||
|
||||
// AuthScope contains Yao permission fields for data scoping
|
||||
// These fields are used by Yao's permission system (when model has permission: true)
|
||||
type AuthScope struct {
|
||||
CreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id
|
||||
UpdatedBy string `json:"__yao_updated_by,omitempty"` // Updater user_id
|
||||
TeamID string `json:"__yao_team_id,omitempty"` // Permission team scope
|
||||
TenantID string `json:"__yao_tenant_id,omitempty"` // Permission tenant scope
|
||||
}
|
||||
|
||||
// CreateRobotRequest - request for CreateRobot()
|
||||
type CreateRobotRequest struct {
|
||||
// Identity (member_id is optional - auto-generated if not provided)
|
||||
MemberID string `json:"member_id,omitempty"` // Unique robot identifier (auto-generated if empty)
|
||||
TeamID string `json:"team_id"` // Team ID (required)
|
||||
|
||||
// Profile
|
||||
DisplayName string `json:"display_name,omitempty"` // Display name
|
||||
Bio string `json:"bio,omitempty"` // Robot description
|
||||
Avatar string `json:"avatar,omitempty"` // Avatar URL
|
||||
|
||||
// Identity & Role
|
||||
SystemPrompt string `json:"system_prompt,omitempty"` // System prompt
|
||||
RoleID string `json:"role_id,omitempty"` // Role within team
|
||||
ManagerID string `json:"manager_id,omitempty"` // Direct manager user_id
|
||||
|
||||
// Status
|
||||
Status string `json:"status,omitempty"` // Member status: active | inactive | pending | suspended
|
||||
RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle | working | paused | error | maintenance
|
||||
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled
|
||||
|
||||
// Communication
|
||||
RobotEmail string `json:"robot_email,omitempty"` // Robot email address
|
||||
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
|
||||
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
|
||||
|
||||
// Capabilities
|
||||
RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON
|
||||
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
|
||||
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
|
||||
LanguageModel string `json:"language_model,omitempty"` // Language model name
|
||||
|
||||
// Limits
|
||||
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
|
||||
|
||||
// Auth scope (optional, used by OpenAPI layer via WithCreateScope)
|
||||
AuthScope *AuthScope `json:"auth_scope,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateRobotRequest - request for UpdateRobot()
|
||||
type UpdateRobotRequest struct {
|
||||
// Profile
|
||||
DisplayName *string `json:"display_name,omitempty"` // Display name
|
||||
Bio *string `json:"bio,omitempty"` // Robot description
|
||||
Avatar *string `json:"avatar,omitempty"` // Avatar URL
|
||||
|
||||
// Identity & Role
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"` // System prompt
|
||||
RoleID *string `json:"role_id,omitempty"` // Role within team
|
||||
ManagerID *string `json:"manager_id,omitempty"` // Direct manager user_id
|
||||
|
||||
// Status
|
||||
Status *string `json:"status,omitempty"` // Member status
|
||||
RobotStatus *string `json:"robot_status,omitempty"` // Robot status
|
||||
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode
|
||||
|
||||
// Communication
|
||||
RobotEmail *string `json:"robot_email,omitempty"` // Robot email address
|
||||
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist
|
||||
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules
|
||||
|
||||
// Capabilities
|
||||
RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON
|
||||
Agents interface{} `json:"agents,omitempty"` // Accessible agents
|
||||
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers
|
||||
LanguageModel *string `json:"language_model,omitempty"` // Language model name
|
||||
|
||||
// Limits
|
||||
CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
|
||||
|
||||
// Auth scope (optional, used by OpenAPI layer via WithUpdateScope)
|
||||
AuthScope *AuthScope `json:"auth_scope,omitempty"`
|
||||
}
|
||||
|
||||
// RobotResponse - response containing robot details for API
|
||||
type RobotResponse struct {
|
||||
// Basic
|
||||
ID int64 `json:"id,omitempty"`
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
Status string `json:"status"`
|
||||
RobotStatus string `json:"robot_status"`
|
||||
AutonomousMode bool `json:"autonomous_mode"`
|
||||
|
||||
// Profile
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
|
||||
// Identity & Role
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
RoleID string `json:"role_id,omitempty"`
|
||||
ManagerID string `json:"manager_id,omitempty"`
|
||||
|
||||
// Communication
|
||||
RobotEmail string `json:"robot_email,omitempty"`
|
||||
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"`
|
||||
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"`
|
||||
|
||||
// Capabilities
|
||||
RobotConfig interface{} `json:"robot_config,omitempty"`
|
||||
Agents interface{} `json:"agents,omitempty"`
|
||||
MCPServers interface{} `json:"mcp_servers,omitempty"`
|
||||
LanguageModel string `json:"language_model,omitempty"`
|
||||
|
||||
// Limits
|
||||
CostLimit float64 `json:"cost_limit,omitempty"`
|
||||
|
||||
// Ownership & Audit
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
JoinedAt *time.Time `json:"joined_at,omitempty"`
|
||||
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id for permission check
|
||||
YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for permission check
|
||||
|
||||
// Timestamps
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
// applyDefaults applies default values to ListQuery
|
||||
|
|
|
|||
2
agent/robot/cache/load.go
vendored
2
agent/robot/cache/load.go
vendored
|
|
@ -18,10 +18,12 @@ var memberFields = []interface{}{
|
|||
"member_id",
|
||||
"team_id",
|
||||
"display_name",
|
||||
"bio",
|
||||
"system_prompt",
|
||||
"robot_status",
|
||||
"autonomous_mode",
|
||||
"robot_config",
|
||||
"robot_email",
|
||||
}
|
||||
|
||||
// SetMemberModel sets the member model name
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import (
|
|||
)
|
||||
|
||||
// Smoke tests to verify basic flow works
|
||||
// These tests use SkipJobIntegration=true to avoid DB dependencies
|
||||
// Real integration tests are in manager_test.go and job_test.go
|
||||
// Real integration tests are in manager_test.go
|
||||
|
||||
func TestExecutorSmoke(t *testing.T) {
|
||||
exec := NewDryRunWithDelay(0)
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@ import (
|
|||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/robot/executor/types"
|
||||
"github.com/yaoapp/yao/agent/robot/job"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/robot/utils"
|
||||
)
|
||||
|
||||
// Executor implements the standard executor with real Agent calls
|
||||
// This is the production executor that:
|
||||
// - Creates Job records for tracking
|
||||
// - Persists execution history to database
|
||||
// - Calls real Agents via Assistant.Stream()
|
||||
// - Logs phase transitions and errors
|
||||
// - Logs phase transitions and errors using kun/log
|
||||
type Executor struct {
|
||||
config types.Config
|
||||
store *store.ExecutionStore
|
||||
|
|
@ -47,36 +47,22 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
return nil, fmt.Errorf("robot cannot be nil")
|
||||
}
|
||||
|
||||
var exec *robottypes.Execution
|
||||
var err error
|
||||
|
||||
// Determine starting phase based on trigger type
|
||||
startPhaseIndex := 0
|
||||
if trigger == robottypes.TriggerHuman || trigger == robottypes.TriggerEvent {
|
||||
startPhaseIndex = 1 // Skip P0 (Inspiration)
|
||||
}
|
||||
|
||||
// Create execution with Job integration
|
||||
if !e.config.SkipJobIntegration {
|
||||
exec, err = job.CreateExecution(ctx, &job.CreateOptions{
|
||||
Robot: robot,
|
||||
TriggerType: trigger,
|
||||
Input: types.BuildTriggerInput(trigger, data),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create execution: %w", err)
|
||||
}
|
||||
} else {
|
||||
exec = &robottypes.Execution{
|
||||
ID: fmt.Sprintf("exec_%d", time.Now().UnixNano()),
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
StartTime: time.Now(),
|
||||
Status: robottypes.ExecPending,
|
||||
Phase: robottypes.AllPhases[startPhaseIndex],
|
||||
Input: types.BuildTriggerInput(trigger, data),
|
||||
}
|
||||
// Create execution (Job system removed, using ExecutionStore only)
|
||||
exec := &robottypes.Execution{
|
||||
ID: utils.NewID(),
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
StartTime: time.Now(),
|
||||
Status: robottypes.ExecPending,
|
||||
Phase: robottypes.AllPhases[startPhaseIndex],
|
||||
Input: types.BuildTriggerInput(trigger, data),
|
||||
}
|
||||
|
||||
// Set robot reference for phase methods
|
||||
|
|
@ -88,17 +74,20 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
record := store.FromExecution(exec)
|
||||
if err := e.store.Save(ctx.Context, record); err != nil {
|
||||
// Log warning but don't fail execution
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist execution record: %v", err))
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
"error": err,
|
||||
}).Warn("Failed to persist execution record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire execution slot
|
||||
if !robot.TryAcquireSlot(exec) {
|
||||
if !e.config.SkipJobIntegration && exec.JobID != "" {
|
||||
_ = job.FailExecution(ctx, exec, robottypes.ErrQuotaExceeded)
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
}).Warn("Execution quota exceeded")
|
||||
return nil, robottypes.ErrQuotaExceeded
|
||||
}
|
||||
defer robot.RemoveExecution(exec.ID)
|
||||
|
|
@ -118,17 +107,19 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
|
||||
// Update status to running
|
||||
exec.Status = robottypes.ExecRunning
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.UpdateStatus(ctx, exec, robottypes.ExecRunning); err != nil {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err))
|
||||
}
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
"trigger_type": string(exec.TriggerType),
|
||||
}).Info("Execution started")
|
||||
|
||||
// Persist running status
|
||||
if !e.config.SkipPersistence && e.store != nil {
|
||||
if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecRunning, ""); err != nil {
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist running status: %v", err))
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"error": err,
|
||||
}).Warn("Failed to persist running status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,9 +127,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
|
||||
exec.Status = robottypes.ExecFailed
|
||||
exec.Error = "simulated failure"
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure"))
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
}).Warn("Simulated failure triggered")
|
||||
// Persist failed status
|
||||
if !e.config.SkipPersistence && e.store != nil {
|
||||
_ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, "simulated failure")
|
||||
|
|
@ -152,9 +144,12 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
if err := e.runPhase(ctx, exec, phase, data); err != nil {
|
||||
exec.Status = robottypes.ExecFailed
|
||||
exec.Error = err.Error()
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.FailExecution(ctx, exec, err)
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
"phase": string(phase),
|
||||
"error": err.Error(),
|
||||
}).Error("Phase execution failed: %v", err)
|
||||
// Persist failed status
|
||||
if !e.config.SkipPersistence && e.store != nil {
|
||||
_ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, err.Error())
|
||||
|
|
@ -168,17 +163,20 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
now := time.Now()
|
||||
exec.EndTime = &now
|
||||
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.CompleteExecution(ctx, exec); err != nil {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err))
|
||||
}
|
||||
}
|
||||
duration := now.Sub(exec.StartTime)
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
}).Info("Execution completed successfully")
|
||||
|
||||
// Persist completed status
|
||||
if !e.config.SkipPersistence && e.store != nil {
|
||||
if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecCompleted, ""); err != nil {
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist completed status: %v", err))
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"error": err,
|
||||
}).Warn("Failed to persist completed status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,11 +187,11 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, phase robottypes.Phase, data interface{}) error {
|
||||
exec.Phase = phase
|
||||
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.UpdatePhase(ctx, exec, phase); err != nil {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update phase to %s: %v", phase, err))
|
||||
}
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
"phase": string(phase),
|
||||
}).Info("Phase started: %s", phase)
|
||||
|
||||
if e.config.OnPhaseStart != nil {
|
||||
e.config.OnPhaseStart(phase)
|
||||
|
|
@ -219,9 +217,12 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.LogPhaseError(ctx, exec, phase, err)
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
"phase": string(phase),
|
||||
"error": err.Error(),
|
||||
}).Error("Phase failed: %s - %v", phase, err)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -231,9 +232,11 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
if phaseData != nil {
|
||||
if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, phaseData); err != nil {
|
||||
// Log warning but don't fail execution
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist phase %s data: %v", phase, err))
|
||||
}
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"phase": string(phase),
|
||||
"error": err,
|
||||
}).Warn("Failed to persist phase %s data: %v", phase, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -242,10 +245,13 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
e.config.OnPhaseEnd(phase)
|
||||
}
|
||||
|
||||
if !e.config.SkipJobIntegration {
|
||||
phaseDuration := time.Since(phaseStart).Milliseconds()
|
||||
_ = job.LogPhaseEnd(ctx, exec, phase, phaseDuration)
|
||||
}
|
||||
phaseDuration := time.Since(phaseStart).Milliseconds()
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
"phase": string(phase),
|
||||
"duration_ms": phaseDuration,
|
||||
}).Info("Phase completed: %s (took %dms)", phase, phaseDuration)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,9 @@ func TestExecutorPersistence(t *testing.T) {
|
|||
|
||||
robot := createPersistenceTestRobot("member_persist_001", "team_persist_001")
|
||||
|
||||
// Create executor with persistence enabled but skip job integration
|
||||
// Create executor with persistence enabled
|
||||
e := standard.NewWithConfig(types.Config{
|
||||
SkipJobIntegration: true,
|
||||
SkipPersistence: false,
|
||||
SkipPersistence: false,
|
||||
})
|
||||
|
||||
// Execute with simulated failure to ensure we get a result
|
||||
|
|
@ -71,8 +70,7 @@ func TestExecutorPersistence(t *testing.T) {
|
|||
robot := createPersistenceTestRobot("member_persist_002", "team_persist_002")
|
||||
|
||||
e := standard.NewWithConfig(types.Config{
|
||||
SkipJobIntegration: true,
|
||||
SkipPersistence: false,
|
||||
SkipPersistence: false,
|
||||
})
|
||||
|
||||
// Execute with simulated failure
|
||||
|
|
@ -104,8 +102,7 @@ func TestExecutorPersistence(t *testing.T) {
|
|||
|
||||
// Create executor with persistence disabled
|
||||
e := standard.NewWithConfig(types.Config{
|
||||
SkipJobIntegration: true,
|
||||
SkipPersistence: true,
|
||||
SkipPersistence: true,
|
||||
})
|
||||
|
||||
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure")
|
||||
|
|
|
|||
|
|
@ -50,9 +50,6 @@ type PhaseExecutor interface {
|
|||
|
||||
// Config holds common executor configuration
|
||||
type Config struct {
|
||||
// SkipJobIntegration skips job system integration (for testing)
|
||||
SkipJobIntegration bool
|
||||
|
||||
// SkipPersistence skips execution record persistence (for testing)
|
||||
SkipPersistence bool
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// This bypasses clock checking and directly submits to pool
|
||||
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
||||
func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error) {
|
||||
m.mu.RLock()
|
||||
if !m.started {
|
||||
|
|
@ -389,10 +390,10 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
|||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Get robot from cache
|
||||
robot := m.cache.Get(memberID)
|
||||
if robot == nil {
|
||||
return "", types.ErrRobotNotFound
|
||||
// Get robot from cache, or lazy-load if not found
|
||||
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, memberID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Check robot status
|
||||
|
|
@ -410,9 +411,18 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
|||
// Submit to pool
|
||||
execID, err := m.pool.Submit(ctx, robot, trigger, data)
|
||||
if err != nil {
|
||||
// If lazy-loaded and submission failed, remove from cache
|
||||
if lazyLoaded {
|
||||
m.cache.Remove(memberID)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// For lazy-loaded robots, schedule cleanup after execution completes
|
||||
if lazyLoaded {
|
||||
m.scheduleCleanup(robot)
|
||||
}
|
||||
|
||||
return execID, nil
|
||||
}
|
||||
|
||||
|
|
@ -420,6 +430,7 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ
|
|||
|
||||
// Intervene processes a human intervention request
|
||||
// Human intervention skips P0 (inspiration) and goes directly to P1 (goals)
|
||||
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
||||
func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
|
||||
m.mu.RLock()
|
||||
if !m.started {
|
||||
|
|
@ -433,10 +444,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Get robot from cache
|
||||
robot := m.cache.Get(req.MemberID)
|
||||
if robot == nil {
|
||||
return nil, types.ErrRobotNotFound
|
||||
// Get robot from cache, or lazy-load if not found
|
||||
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check robot status
|
||||
|
|
@ -460,6 +471,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
|
||||
// Handle plan.add action - schedule for later
|
||||
if req.Action == types.ActionPlanAdd && req.PlanTime != nil {
|
||||
// If lazy-loaded but not executing, remove immediately
|
||||
if lazyLoaded {
|
||||
m.cache.Remove(req.MemberID)
|
||||
}
|
||||
// TODO: Add to plan queue (Phase 11.3)
|
||||
return &types.ExecutionResult{
|
||||
Status: types.ExecPending,
|
||||
|
|
@ -473,12 +488,21 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
// Submit to pool with executor mode
|
||||
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerHuman, triggerInput, executorMode)
|
||||
if err != nil {
|
||||
// If lazy-loaded and submission failed, remove from cache
|
||||
if lazyLoaded {
|
||||
m.cache.Remove(req.MemberID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track execution for pause/resume/stop
|
||||
m.execController.Track(execID, req.MemberID, req.TeamID)
|
||||
|
||||
// For lazy-loaded robots, schedule cleanup after execution completes
|
||||
if lazyLoaded {
|
||||
m.scheduleCleanup(robot)
|
||||
}
|
||||
|
||||
return &types.ExecutionResult{
|
||||
ExecutionID: execID,
|
||||
Status: types.ExecPending,
|
||||
|
|
@ -488,6 +512,7 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
|
||||
// HandleEvent processes an event trigger request
|
||||
// Event trigger skips P0 (inspiration) and goes directly to P1 (goals)
|
||||
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
||||
func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
|
||||
m.mu.RLock()
|
||||
if !m.started {
|
||||
|
|
@ -501,10 +526,10 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Get robot from cache
|
||||
robot := m.cache.Get(req.MemberID)
|
||||
if robot == nil {
|
||||
return nil, types.ErrRobotNotFound
|
||||
// Get robot from cache, or lazy-load if not found
|
||||
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check robot status
|
||||
|
|
@ -528,12 +553,21 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
|
|||
// Submit to pool with executor mode
|
||||
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerEvent, triggerInput, executorMode)
|
||||
if err != nil {
|
||||
// If lazy-loaded and submission failed, remove from cache
|
||||
if lazyLoaded {
|
||||
m.cache.Remove(req.MemberID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track execution for pause/resume/stop
|
||||
m.execController.Track(execID, req.MemberID, "")
|
||||
|
||||
// For lazy-loaded robots, schedule cleanup after execution completes
|
||||
if lazyLoaded {
|
||||
m.scheduleCleanup(robot)
|
||||
}
|
||||
|
||||
return &types.ExecutionResult{
|
||||
ExecutionID: execID,
|
||||
Status: types.ExecPending,
|
||||
|
|
@ -579,6 +613,70 @@ func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledE
|
|||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
// getOrLoadRobot gets a robot from cache, or lazy-loads from DB if not found
|
||||
// Returns: robot, wasLazyLoaded, error
|
||||
func (m *Manager) getOrLoadRobot(ctx *types.Context, memberID string) (*types.Robot, bool, error) {
|
||||
// Try cache first
|
||||
robot := m.cache.Get(memberID)
|
||||
if robot != nil {
|
||||
return robot, false, nil
|
||||
}
|
||||
|
||||
// Not in cache - lazy load from database
|
||||
robot, err := m.cache.LoadByID(ctx, memberID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Add to cache temporarily for execution tracking
|
||||
m.cache.Add(robot)
|
||||
|
||||
// Return with lazyLoaded=true to indicate cleanup needed after execution
|
||||
return robot, true, nil
|
||||
}
|
||||
|
||||
// scheduleCleanup schedules removal of a lazy-loaded robot after all executions complete
|
||||
// This runs in a goroutine that monitors the robot's execution count
|
||||
func (m *Manager) scheduleCleanup(robot *types.Robot) {
|
||||
go func() {
|
||||
memberID := robot.MemberID
|
||||
|
||||
// Poll every 5 seconds to check if all executions are done
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Timeout after 24 hours to prevent memory leaks
|
||||
timeout := time.After(24 * time.Hour)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
// Timeout - force cleanup
|
||||
m.cache.Remove(memberID)
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
// Check if robot still exists in cache
|
||||
r := m.cache.Get(memberID)
|
||||
if r == nil {
|
||||
// Already removed
|
||||
return
|
||||
}
|
||||
|
||||
// Check if all executions are done
|
||||
if r.RunningCount() == 0 {
|
||||
// Only remove if still non-autonomous
|
||||
// (user might have changed it during execution)
|
||||
if !r.AutonomousMode {
|
||||
m.cache.Remove(memberID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// resolveExecutorMode determines the executor mode to use
|
||||
// Priority: request > robot config > default (standard)
|
||||
func (m *Manager) resolveExecutorMode(requestMode types.ExecutorMode, robot *types.Robot) types.ExecutorMode {
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,272 @@ func setupTestRobotsWithEventConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== Lazy Load Tests for Non-Autonomous Robots ====================
|
||||
|
||||
// TestManagerLazyLoadNonAutonomous tests that non-autonomous robots are lazy-loaded on demand
|
||||
// and automatically cleaned up after execution completes
|
||||
func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobotsWithNonAutonomous(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
t.Run("non-autonomous robot not in cache on startup", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
// Non-autonomous robot should NOT be in cache
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.Nil(t, robot, "Non-autonomous robot should not be pre-loaded into cache")
|
||||
|
||||
// Autonomous robot SHOULD be in cache
|
||||
autoRobot := m.Cache().Get("robot_test_manager_times")
|
||||
assert.NotNil(t, autoRobot, "Autonomous robot should be in cache")
|
||||
})
|
||||
|
||||
t.Run("TriggerManual lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand"))
|
||||
|
||||
// Trigger the non-autonomous robot manually
|
||||
execID, err := m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, execID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache")
|
||||
assert.Equal(t, "robot_test_manager_on_demand", robot.MemberID)
|
||||
assert.False(t, robot.AutonomousMode)
|
||||
})
|
||||
|
||||
t.Run("Intervene lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_intervene"))
|
||||
|
||||
// Intervene on the non-autonomous robot
|
||||
req := &types.InterveneRequest{
|
||||
TeamID: "team_test_manager",
|
||||
MemberID: "robot_test_manager_on_demand_intervene",
|
||||
Action: types.ActionTaskAdd,
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Test lazy load via intervene"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := m.Intervene(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result.ExecutionID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand_intervene")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via Intervene")
|
||||
})
|
||||
|
||||
t.Run("HandleEvent lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_event"))
|
||||
|
||||
// Send event to the non-autonomous robot
|
||||
req := &types.EventRequest{
|
||||
MemberID: "robot_test_manager_on_demand_event",
|
||||
Source: "webhook",
|
||||
EventType: "data.updated",
|
||||
Data: map[string]interface{}{"test": true},
|
||||
}
|
||||
|
||||
result, err := m.HandleEvent(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result.ExecutionID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand_event")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via HandleEvent")
|
||||
})
|
||||
|
||||
t.Run("lazy-loaded robot is cleaned up after execution completes", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Trigger the non-autonomous robot
|
||||
_, err = m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Robot should be in cache immediately after trigger
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.NotNil(t, robot, "Robot should be in cache after trigger")
|
||||
|
||||
// Wait for execution to complete and cleanup to happen
|
||||
// The stub executor completes quickly, and cleanup runs every 5 seconds
|
||||
// We wait up to 10 seconds for the cleanup goroutine to remove the robot
|
||||
var removed bool
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if m.Cache().Get("robot_test_manager_on_demand") == nil {
|
||||
removed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, removed, "Non-autonomous robot should be removed from cache after execution completes")
|
||||
})
|
||||
|
||||
t.Run("trigger non-existent robot returns error", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Try to trigger a robot that doesn't exist in DB
|
||||
_, err = m.TriggerManual(ctx, "robot_nonexistent_xyz", types.TriggerHuman, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrRobotNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// setupTestRobotsWithNonAutonomous creates test robots including non-autonomous ones
|
||||
func setupTestRobotsWithNonAutonomous(t *testing.T) {
|
||||
// First setup the autonomous robots
|
||||
setupTestRobotsWithClockConfig(t)
|
||||
|
||||
// Add non-autonomous robots
|
||||
qb := capsule.Query()
|
||||
m := model.Select("__yao.member")
|
||||
tableName := m.MetaData.Table.Name
|
||||
|
||||
// Non-autonomous robot 1: for TriggerManual test
|
||||
robotConfigOnDemand := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "On-Demand Robot",
|
||||
},
|
||||
"triggers": map[string]interface{}{
|
||||
"clock": map[string]interface{}{"enabled": false},
|
||||
"intervene": map[string]interface{}{"enabled": true},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 2,
|
||||
"queue": 5,
|
||||
},
|
||||
}
|
||||
configOnDemandJSON, _ := json.Marshal(robotConfigOnDemand)
|
||||
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_manager_on_demand",
|
||||
"team_id": "team_test_manager",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test On-Demand Robot",
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": false, // Non-autonomous!
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configOnDemandJSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_manager_on_demand: %v", err)
|
||||
}
|
||||
|
||||
// Non-autonomous robot 2: for Intervene test
|
||||
robotConfigOnDemandIntervene := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "On-Demand Intervene Robot",
|
||||
},
|
||||
"triggers": map[string]interface{}{
|
||||
"clock": map[string]interface{}{"enabled": false},
|
||||
"intervene": map[string]interface{}{"enabled": true},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 2,
|
||||
},
|
||||
}
|
||||
configOnDemandInterveneJSON, _ := json.Marshal(robotConfigOnDemandIntervene)
|
||||
|
||||
err = qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_manager_on_demand_intervene",
|
||||
"team_id": "team_test_manager",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test On-Demand Intervene Robot",
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": false, // Non-autonomous!
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configOnDemandInterveneJSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_manager_on_demand_intervene: %v", err)
|
||||
}
|
||||
|
||||
// Non-autonomous robot 3: for HandleEvent test
|
||||
robotConfigOnDemandEvent := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "On-Demand Event Robot",
|
||||
},
|
||||
"triggers": map[string]interface{}{
|
||||
"clock": map[string]interface{}{"enabled": false},
|
||||
"event": map[string]interface{}{"enabled": true},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 2,
|
||||
},
|
||||
}
|
||||
configOnDemandEventJSON, _ := json.Marshal(robotConfigOnDemandEvent)
|
||||
|
||||
err = qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_manager_on_demand_event",
|
||||
"team_id": "team_test_manager",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test On-Demand Event Robot",
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": false, // Non-autonomous!
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configOnDemandEventJSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_manager_on_demand_event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupTestRobots removes all test robot records
|
||||
func cleanupTestRobots(t *testing.T) {
|
||||
qb := capsule.Query()
|
||||
|
|
@ -1417,6 +1683,10 @@ func cleanupTestRobots(t *testing.T) {
|
|||
"robot_test_manager_intervene_disabled",
|
||||
"robot_test_manager_event",
|
||||
"robot_test_manager_event_disabled",
|
||||
// Non-autonomous robots
|
||||
"robot_test_manager_on_demand",
|
||||
"robot_test_manager_on_demand_intervene",
|
||||
"robot_test_manager_on_demand_event",
|
||||
}
|
||||
|
||||
for _, id := range testRobotIDs {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@ import (
|
|||
// ExecutionRecord - persistent storage for robot execution history
|
||||
// Maps to __yao.agent_execution model
|
||||
type ExecutionRecord struct {
|
||||
ID int64 `json:"id,omitempty"` // Auto-increment primary key
|
||||
ExecutionID string `json:"execution_id"` // Unique execution identifier
|
||||
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||
TeamID string `json:"team_id"` // Team ID
|
||||
JobID string `json:"job_id,omitempty"` // Linked job.Job ID
|
||||
TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event
|
||||
ID int64 `json:"id,omitempty"` // Auto-increment primary key
|
||||
ExecutionID string `json:"execution_id"` // Unique execution identifier
|
||||
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||
TeamID string `json:"team_id"` // Team ID
|
||||
TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event
|
||||
|
||||
// Status tracking (synced with runtime Execution)
|
||||
Status types.ExecStatus `json:"status"` // pending | running | completed | failed | cancelled
|
||||
|
|
@ -344,9 +343,6 @@ func (s *ExecutionStore) recordToMap(record *ExecutionRecord) map[string]interfa
|
|||
"phase": string(record.Phase),
|
||||
}
|
||||
|
||||
if record.JobID != "" {
|
||||
data["job_id"] = record.JobID
|
||||
}
|
||||
if record.Error != "" {
|
||||
data["error"] = record.Error
|
||||
}
|
||||
|
|
@ -408,9 +404,6 @@ func (s *ExecutionStore) mapToRecord(row map[string]interface{}) (*ExecutionReco
|
|||
if v, ok := row["team_id"].(string); ok {
|
||||
record.TeamID = v
|
||||
}
|
||||
if v, ok := row["job_id"].(string); ok {
|
||||
record.JobID = v
|
||||
}
|
||||
if v, ok := row["trigger_type"].(string); ok {
|
||||
record.TriggerType = types.TriggerType(v)
|
||||
}
|
||||
|
|
@ -634,7 +627,6 @@ func FromExecution(exec *types.Execution) *ExecutionRecord {
|
|||
ExecutionID: exec.ID,
|
||||
MemberID: exec.MemberID,
|
||||
TeamID: exec.TeamID,
|
||||
JobID: exec.JobID,
|
||||
TriggerType: exec.TriggerType,
|
||||
Status: exec.Status,
|
||||
Phase: exec.Phase,
|
||||
|
|
@ -673,7 +665,6 @@ func (r *ExecutionRecord) ToExecution() *types.Execution {
|
|||
ID: r.ExecutionID,
|
||||
MemberID: r.MemberID,
|
||||
TeamID: r.TeamID,
|
||||
JobID: r.JobID,
|
||||
TriggerType: r.TriggerType,
|
||||
Status: r.Status,
|
||||
Phase: r.Phase,
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ func TestExecutionStoreSave(t *testing.T) {
|
|||
ExecutionID: "exec_test_save_001",
|
||||
MemberID: "member_test_001",
|
||||
TeamID: "team_test_001",
|
||||
JobID: "job_test_001",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecPending,
|
||||
Phase: types.PhaseInspiration,
|
||||
|
|
@ -53,7 +52,6 @@ func TestExecutionStoreSave(t *testing.T) {
|
|||
assert.Equal(t, "exec_test_save_001", saved.ExecutionID)
|
||||
assert.Equal(t, "member_test_001", saved.MemberID)
|
||||
assert.Equal(t, "team_test_001", saved.TeamID)
|
||||
assert.Equal(t, "job_test_001", saved.JobID)
|
||||
assert.Equal(t, types.TriggerClock, saved.TriggerType)
|
||||
assert.Equal(t, types.ExecPending, saved.Status)
|
||||
assert.Equal(t, types.PhaseInspiration, saved.Phase)
|
||||
|
|
@ -574,7 +572,6 @@ func TestExecutionRecordConversion(t *testing.T) {
|
|||
ID: "exec_convert_001",
|
||||
MemberID: "member_convert_001",
|
||||
TeamID: "team_convert_001",
|
||||
JobID: "job_convert_001",
|
||||
TriggerType: types.TriggerHuman,
|
||||
Status: types.ExecCompleted,
|
||||
Phase: types.PhaseDelivery,
|
||||
|
|
@ -600,7 +597,6 @@ func TestExecutionRecordConversion(t *testing.T) {
|
|||
assert.Equal(t, "exec_convert_001", record.ExecutionID)
|
||||
assert.Equal(t, "member_convert_001", record.MemberID)
|
||||
assert.Equal(t, "team_convert_001", record.TeamID)
|
||||
assert.Equal(t, "job_convert_001", record.JobID)
|
||||
assert.Equal(t, types.TriggerHuman, record.TriggerType)
|
||||
assert.Equal(t, types.ExecCompleted, record.Status)
|
||||
assert.Equal(t, types.PhaseDelivery, record.Phase)
|
||||
|
|
@ -621,7 +617,6 @@ func TestExecutionRecordConversion(t *testing.T) {
|
|||
ExecutionID: "exec_convert_002",
|
||||
MemberID: "member_convert_002",
|
||||
TeamID: "team_convert_002",
|
||||
JobID: "job_convert_002",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseRun,
|
||||
|
|
@ -646,7 +641,6 @@ func TestExecutionRecordConversion(t *testing.T) {
|
|||
assert.Equal(t, "exec_convert_002", exec.ID)
|
||||
assert.Equal(t, "member_convert_002", exec.MemberID)
|
||||
assert.Equal(t, "team_convert_002", exec.TeamID)
|
||||
assert.Equal(t, "job_convert_002", exec.JobID)
|
||||
assert.Equal(t, types.TriggerClock, exec.TriggerType)
|
||||
assert.Equal(t, types.ExecRunning, exec.Status)
|
||||
assert.Equal(t, types.PhaseRun, exec.Phase)
|
||||
|
|
@ -686,7 +680,6 @@ func setupTestExecution(t *testing.T, s *store.ExecutionStore, ctx context.Conte
|
|||
ExecutionID: "exec_test_get_001",
|
||||
MemberID: "member_test_get",
|
||||
TeamID: "team_test_get",
|
||||
JobID: "job_test_get",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecCompleted,
|
||||
Phase: types.PhaseDelivery,
|
||||
|
|
|
|||
640
agent/robot/store/robot.go
Normal file
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)
|
||||
// Relationship: 1 Robot : N Executions (concurrent)
|
||||
// Each trigger creates a new Execution (mapped to job.Job)
|
||||
// Each trigger creates a new Execution (stored in __yao.agent_execution)
|
||||
type Robot struct {
|
||||
// From __yao.member
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"` // Robot's description (from __yao.member.bio)
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Status RobotStatus `json:"robot_status"`
|
||||
AutonomousMode bool `json:"autonomous_mode"`
|
||||
|
|
@ -116,8 +117,7 @@ func (r *Robot) GetExecutions() []*Execution {
|
|||
}
|
||||
|
||||
// Execution - single execution instance
|
||||
// Each trigger creates a new Execution, mapped to a job.Job for monitoring
|
||||
// Relationship: 1 Execution = 1 job.Job
|
||||
// Each trigger creates a new Execution, stored in ExecutionStore
|
||||
type Execution struct {
|
||||
ID string `json:"id"` // unique execution ID
|
||||
MemberID string `json:"member_id"` // robot member ID
|
||||
|
|
@ -129,9 +129,6 @@ type Execution struct {
|
|||
Phase Phase `json:"phase"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// Job integration (each Execution = 1 job.Job)
|
||||
JobID string `json:"job_id"` // corresponding job.Job ID
|
||||
|
||||
// Trigger input (stored for traceability)
|
||||
Input *TriggerInput `json:"input,omitempty"` // original trigger input
|
||||
|
||||
|
|
@ -381,6 +378,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
|
|||
MemberID: memberID,
|
||||
TeamID: teamID,
|
||||
DisplayName: getString(m, "display_name"),
|
||||
Bio: getString(m, "bio"),
|
||||
SystemPrompt: getString(m, "system_prompt"),
|
||||
AutonomousMode: getBool(m, "autonomous_mode"),
|
||||
RobotEmail: getString(m, "robot_email"),
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@ func TestExecutionStructure(t *testing.T) {
|
|||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseGoals,
|
||||
JobID: "job1",
|
||||
}
|
||||
|
||||
assert.Equal(t, "exec1", exec.ID)
|
||||
|
|
@ -371,7 +370,6 @@ func TestExecutionStructure(t *testing.T) {
|
|||
assert.Equal(t, types.TriggerClock, exec.TriggerType)
|
||||
assert.Equal(t, types.ExecRunning, exec.Status)
|
||||
assert.Equal(t, types.PhaseGoals, exec.Phase)
|
||||
assert.Equal(t, "job1", exec.JobID)
|
||||
})
|
||||
|
||||
t.Run("execution with trigger input", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,384 @@ package utils
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ==================== To<Type> Functions ====================
|
||||
// Convert any value to specified type (safe, returns zero value on failure)
|
||||
|
||||
// ToString converts any value to string
|
||||
func ToString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case []byte:
|
||||
return string(val)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case int8:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case int16:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case uint:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case uint8:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case uint16:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case uint32:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case uint64:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case float32:
|
||||
return fmt.Sprintf("%g", val)
|
||||
case float64:
|
||||
return fmt.Sprintf("%g", val)
|
||||
case bool:
|
||||
if val {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
default:
|
||||
if str, err := json.Marshal(v); err == nil {
|
||||
return string(str)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ToBool converts any value to bool
|
||||
func ToBool(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case int:
|
||||
return b != 0
|
||||
case int8:
|
||||
return b != 0
|
||||
case int16:
|
||||
return b != 0
|
||||
case int32:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case uint:
|
||||
return b != 0
|
||||
case uint8:
|
||||
return b != 0
|
||||
case uint16:
|
||||
return b != 0
|
||||
case uint32:
|
||||
return b != 0
|
||||
case uint64:
|
||||
return b != 0
|
||||
case float32:
|
||||
return b != 0
|
||||
case float64:
|
||||
return b != 0
|
||||
case string:
|
||||
return b == "true" || b == "1" || b == "yes" || b == "on"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToInt converts any value to int
|
||||
func ToInt(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int8:
|
||||
return int(n)
|
||||
case int16:
|
||||
return int(n)
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case uint:
|
||||
return int(n)
|
||||
case uint8:
|
||||
return int(n)
|
||||
case uint16:
|
||||
return int(n)
|
||||
case uint32:
|
||||
return int(n)
|
||||
case uint64:
|
||||
return int(n)
|
||||
case float32:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case string:
|
||||
var i int
|
||||
fmt.Sscanf(n, "%d", &i)
|
||||
return i
|
||||
case bool:
|
||||
if n {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ToInt64 converts any value to int64
|
||||
func ToInt64(v interface{}) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case int8:
|
||||
return int64(n)
|
||||
case int16:
|
||||
return int64(n)
|
||||
case int32:
|
||||
return int64(n)
|
||||
case uint:
|
||||
return int64(n)
|
||||
case uint8:
|
||||
return int64(n)
|
||||
case uint16:
|
||||
return int64(n)
|
||||
case uint32:
|
||||
return int64(n)
|
||||
case uint64:
|
||||
return int64(n)
|
||||
case float32:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case string:
|
||||
var i int64
|
||||
fmt.Sscanf(n, "%d", &i)
|
||||
return i
|
||||
case bool:
|
||||
if n {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ToFloat64 converts any value to float64
|
||||
func ToFloat64(v interface{}) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch f := v.(type) {
|
||||
case float64:
|
||||
return f
|
||||
case float32:
|
||||
return float64(f)
|
||||
case int:
|
||||
return float64(f)
|
||||
case int8:
|
||||
return float64(f)
|
||||
case int16:
|
||||
return float64(f)
|
||||
case int32:
|
||||
return float64(f)
|
||||
case int64:
|
||||
return float64(f)
|
||||
case uint:
|
||||
return float64(f)
|
||||
case uint8:
|
||||
return float64(f)
|
||||
case uint16:
|
||||
return float64(f)
|
||||
case uint32:
|
||||
return float64(f)
|
||||
case uint64:
|
||||
return float64(f)
|
||||
case string:
|
||||
var result float64
|
||||
fmt.Sscanf(f, "%f", &result)
|
||||
return result
|
||||
case bool:
|
||||
if f {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ToTimestamp converts any value to *time.Time
|
||||
// Handles: time.Time, *time.Time, string (various formats), int64/float64 (unix timestamp)
|
||||
func ToTimestamp(v interface{}) *time.Time {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
return &t
|
||||
case *time.Time:
|
||||
return t
|
||||
case string:
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
// Try common time formats
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, format := range formats {
|
||||
if parsed, err := time.Parse(format, t); err == nil {
|
||||
return &parsed
|
||||
}
|
||||
}
|
||||
case int64:
|
||||
// Unix timestamp (seconds)
|
||||
parsed := time.Unix(t, 0)
|
||||
return &parsed
|
||||
case int:
|
||||
parsed := time.Unix(int64(t), 0)
|
||||
return &parsed
|
||||
case float64:
|
||||
// Unix timestamp (seconds as float)
|
||||
parsed := time.Unix(int64(t), 0)
|
||||
return &parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToJSONValue parses JSON from string/[]byte or returns already-parsed value
|
||||
func ToJSONValue(v interface{}) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch data := v.(type) {
|
||||
case string:
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
var result interface{}
|
||||
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
case []byte:
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Already parsed
|
||||
return data
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Get<Type> Functions ====================
|
||||
// Safely get typed value from map[string]interface{}
|
||||
|
||||
// GetString safely gets a string value from map
|
||||
func GetString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key]; ok {
|
||||
return ToString(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetBool safely gets a bool value from map
|
||||
func GetBool(m map[string]interface{}, key string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := m[key]; ok {
|
||||
return ToBool(v)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetInt safely gets an int value from map
|
||||
func GetInt(m map[string]interface{}, key string) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := m[key]; ok {
|
||||
return ToInt(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetInt64 safely gets an int64 value from map
|
||||
func GetInt64(m map[string]interface{}, key string) int64 {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := m[key]; ok {
|
||||
return ToInt64(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetFloat64 safely gets a float64 value from map
|
||||
func GetFloat64(m map[string]interface{}, key string) float64 {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := m[key]; ok {
|
||||
return ToFloat64(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetTimestamp safely gets a *time.Time value from map
|
||||
func GetTimestamp(m map[string]interface{}, key string) *time.Time {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if v, ok := m[key]; ok {
|
||||
return ToTimestamp(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJSONValue safely gets a parsed JSON value from map
|
||||
func GetJSONValue(m map[string]interface{}, key string) interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if v, ok := m[key]; ok {
|
||||
return ToJSONValue(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== JSON/Map Conversion ====================
|
||||
|
||||
// ToJSON converts any value to JSON string
|
||||
func ToJSON(v interface{}) (string, error) {
|
||||
data, err := json.Marshal(v)
|
||||
|
|
@ -14,7 +390,7 @@ func ToJSON(v interface{}) (string, error) {
|
|||
return string(data), nil
|
||||
}
|
||||
|
||||
// FromJSON parses JSON string to target
|
||||
// FromJSON parses JSON string to target struct
|
||||
func FromJSON(jsonStr string, target interface{}) error {
|
||||
return json.Unmarshal([]byte(jsonStr), target)
|
||||
}
|
||||
|
|
@ -25,12 +401,10 @@ func ToMap(v interface{}) (map[string]interface{}, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
@ -43,29 +417,7 @@ func FromMap(m map[string]interface{}, target interface{}) error {
|
|||
return json.Unmarshal(data, target)
|
||||
}
|
||||
|
||||
// ToString converts any value to string
|
||||
func ToString(v interface{}) string {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case []byte:
|
||||
return string(val)
|
||||
case int, int8, int16, int32, int64:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case float32, float64:
|
||||
return fmt.Sprintf("%f", val)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", val)
|
||||
default:
|
||||
// Fallback to JSON
|
||||
if str, err := ToJSON(v); err == nil {
|
||||
return str
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
// ==================== Map Utilities ====================
|
||||
|
||||
// MergeMap merges source map into target map (shallow copy)
|
||||
func MergeMap(target, source map[string]interface{}) map[string]interface{} {
|
||||
|
|
@ -89,58 +441,3 @@ func CloneMap(m map[string]interface{}) map[string]interface{} {
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetString safely gets a string value from map
|
||||
func GetString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
return ToString(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetBool safely gets a bool value from map
|
||||
func GetBool(m map[string]interface{}, key string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case int:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case float64:
|
||||
return b != 0
|
||||
case string:
|
||||
return b == "true" || b == "1"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetInt safely gets an int value from map
|
||||
func GetInt(m map[string]interface{}, key string) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case string:
|
||||
var i int
|
||||
fmt.Sscanf(n, "%d", &i)
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
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
|
||||
type AssistantFilter struct {
|
||||
Tags []string `json:"tags,omitempty"` // Filter by tags
|
||||
Type string `json:"type,omitempty"` // Filter by type
|
||||
Type string `json:"type,omitempty"` // Filter by type (single value)
|
||||
Types []string `json:"types,omitempty"` // Filter by types (multiple values, IN query)
|
||||
Keywords string `json:"keywords,omitempty"` // Search in name and description
|
||||
Connector string `json:"connector,omitempty"` // Filter by connector
|
||||
AssistantID string `json:"assistant_id,omitempty"` // Filter by assistant ID
|
||||
|
|
|
|||
|
|
@ -353,11 +353,16 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
|||
})
|
||||
}
|
||||
|
||||
// Apply type filter if provided
|
||||
// Apply type filter if provided (single value)
|
||||
if filter.Type != "" {
|
||||
qb.Where("type", filter.Type)
|
||||
}
|
||||
|
||||
// Apply types filter if provided (multiple values, IN query)
|
||||
if len(filter.Types) > 0 {
|
||||
qb.WhereIn("type", filter.Types)
|
||||
}
|
||||
|
||||
// Apply connector filter if provided
|
||||
if filter.Connector != "" {
|
||||
qb.Where("connector", filter.Connector)
|
||||
|
|
|
|||
|
|
@ -40,13 +40,14 @@ type DSL struct {
|
|||
// Uses the default assistant settings
|
||||
// ===============================
|
||||
type Uses struct {
|
||||
Default string `json:"default,omitempty" yaml:"default,omitempty"` // The default assistant to use
|
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` // The assistant for generating the topic title.
|
||||
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // The assistant for generating the prompt.
|
||||
Vision string `json:"vision,omitempty" yaml:"vision,omitempty"` // The assistant for generating the image/video description, if the assistant enable the vision and model not support vision, use the vision model to describe the image/video, and return the messages with the image/video's description. Format: "agent" or "mcp:mcp_server_id"
|
||||
Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // The assistant for processing audio (speech-to-text, text-to-speech). If the model doesn't support audio, use this to convert audio to text. Format: "agent" or "mcp:mcp_server_id"
|
||||
Search string `json:"search,omitempty" yaml:"search,omitempty"` // The assistant for searching the knowledge, global web search. If not set, and the assistant enable the knowledge, it will search the result from the knowledge automatically.
|
||||
Fetch string `json:"fetch,omitempty" yaml:"fetch,omitempty"` // The assistant for fetching the http/https/ftp/sftp/etc. file, and return the file's content. if not set, use the http process to fetch the file.
|
||||
Default string `json:"default,omitempty" yaml:"default,omitempty"` // The default assistant to use
|
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` // The assistant for generating the topic title.
|
||||
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // The assistant for generating the prompt.
|
||||
RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // The assistant for generating Robot's system prompt (responsibilities description).
|
||||
Vision string `json:"vision,omitempty" yaml:"vision,omitempty"` // The assistant for generating the image/video description, if the assistant enable the vision and model not support vision, use the vision model to describe the image/video, and return the messages with the image/video's description. Format: "agent" or "mcp:mcp_server_id"
|
||||
Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // The assistant for processing audio (speech-to-text, text-to-speech). If the model doesn't support audio, use this to convert audio to text. Format: "agent" or "mcp:mcp_server_id"
|
||||
Search string `json:"search,omitempty" yaml:"search,omitempty"` // The assistant for searching the knowledge, global web search. If not set, and the assistant enable the knowledge, it will search the result from the knowledge automatically.
|
||||
Fetch string `json:"fetch,omitempty" yaml:"fetch,omitempty"` // The assistant for fetching the http/https/ftp/sftp/etc. file, and return the file's content. if not set, use the http process to fetch the file.
|
||||
|
||||
// Search-related processing tools (NLP)
|
||||
Web string `json:"web,omitempty" yaml:"web,omitempty"` // Web search handler: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
|
|
@ -58,13 +59,14 @@ type Uses struct {
|
|||
// System configures connectors for system agents
|
||||
// ===============================
|
||||
type System struct {
|
||||
Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents
|
||||
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent
|
||||
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent
|
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent
|
||||
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // Connector for __yao.prompt agent
|
||||
NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent
|
||||
Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent
|
||||
Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents
|
||||
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent
|
||||
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent
|
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent
|
||||
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // Connector for __yao.prompt agent
|
||||
RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // Connector for __yao.robot_prompt agent
|
||||
NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent
|
||||
Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent
|
||||
}
|
||||
|
||||
// Mention Structure
|
||||
|
|
|
|||
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 (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/agent"
|
||||
robotapi "github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/aigc"
|
||||
"github.com/yaoapp/yao/api"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
|
|
@ -355,6 +357,17 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
|
|||
warnings = append(warnings, Warning{Widget: "Agent", Error: err})
|
||||
}
|
||||
|
||||
// Start Robot Agent System (async, non-blocking)
|
||||
// This starts the robot scheduler for autonomous mode robots
|
||||
go func() {
|
||||
if err := robotapi.Start(); err != nil {
|
||||
// Log warning but don't block application startup
|
||||
// The robot system can operate without the manager running
|
||||
// (API calls will fall back to direct database queries)
|
||||
log.Printf("[Robot Agent] Warning: failed to start robot agent system: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
for name, hook := range LoadHooks {
|
||||
err = hook(cfg)
|
||||
if err != nil {
|
||||
|
|
@ -396,6 +409,13 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
|
|||
func Unload() (err error) {
|
||||
defer func() { err = exception.Catch(recover()) }()
|
||||
|
||||
// Stop Robot Agent System
|
||||
if robotapi.IsRunning() {
|
||||
if stopErr := robotapi.Stop(); stopErr != nil {
|
||||
log.Printf("[Robot Agent] Warning: failed to stop robot agent system: %v", stopErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop Runtime
|
||||
err = runtime.Stop()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package agent
|
|||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/agent/robot"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
|
|
@ -26,4 +27,8 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
|
||||
// Assistant Actions
|
||||
// group.POST("/assistants/:id/call", agent.HandleAssistantCall) // POST /assistants/:id/call - Execute assistant API
|
||||
|
||||
// Robot routes - Attach as sub-router
|
||||
// Routes: GET/POST /robots, GET/PUT/DELETE /robots/:id, GET /robots/:id/status
|
||||
robot.Attach(group.Group("/robots"), oauth)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,12 +78,24 @@ func ListAssistants(c *gin.Context) {
|
|||
// Parse filter parameters
|
||||
keywords := strings.TrimSpace(c.Query("keywords"))
|
||||
typeParam := strings.TrimSpace(c.Query("type"))
|
||||
if typeParam == "" {
|
||||
typeParam = "assistant" // Default type
|
||||
}
|
||||
connector := strings.TrimSpace(c.Query("connector"))
|
||||
assistantID := strings.TrimSpace(c.Query("assistant_id"))
|
||||
|
||||
// Parse types (multiple, comma-separated for IN query)
|
||||
var types []string
|
||||
if typesParam := c.Query("types"); typesParam != "" {
|
||||
types = strings.Split(typesParam, ",")
|
||||
// Trim spaces
|
||||
for i, t := range types {
|
||||
types[i] = strings.TrimSpace(t)
|
||||
}
|
||||
}
|
||||
|
||||
// Set default type only if neither type nor types is specified
|
||||
if typeParam == "" && len(types) == 0 {
|
||||
typeParam = "assistant" // Default type
|
||||
}
|
||||
|
||||
// Parse assistant IDs (multiple)
|
||||
var assistantIDs []string
|
||||
if assistantIDsParam := c.Query("assistant_ids"); assistantIDsParam != "" {
|
||||
|
|
@ -131,6 +143,7 @@ func ListAssistants(c *gin.Context) {
|
|||
PageSize: pagesize,
|
||||
Keywords: keywords,
|
||||
Type: typeParam,
|
||||
Types: types,
|
||||
Connector: connector,
|
||||
AssistantID: assistantID,
|
||||
AssistantIDs: assistantIDs,
|
||||
|
|
|
|||
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
|
||||
PageSize int
|
||||
Keywords string
|
||||
Type string
|
||||
Type string // Single type filter
|
||||
Types []string // Multiple types filter (IN query)
|
||||
Connector string
|
||||
AssistantID string
|
||||
AssistantIDs []string
|
||||
|
|
@ -70,6 +71,7 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt
|
|||
Keywords: params.Keywords,
|
||||
Tags: params.Tags,
|
||||
Type: params.Type,
|
||||
Types: params.Types,
|
||||
Connector: params.Connector,
|
||||
AssistantID: params.AssistantID,
|
||||
AssistantIDs: params.AssistantIDs,
|
||||
|
|
@ -79,8 +81,8 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt
|
|||
Automated: params.Automated,
|
||||
}
|
||||
|
||||
// Set default type if not specified
|
||||
if filter.Type == "" {
|
||||
// Set default type if not specified (only when Types is also empty)
|
||||
if filter.Type == "" && len(filter.Types) == 0 {
|
||||
filter.Type = "assistant"
|
||||
}
|
||||
|
||||
|
|
|
|||
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