Merge pull request #1418 from trheyi/main
Implement Autonomous Agent (In Progress)
This commit is contained in:
commit
b7eb50857d
44 changed files with 9312 additions and 652 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
# Autonomous Agent
|
||||
# Robot Agent
|
||||
|
||||
## 1. What is it?
|
||||
|
||||
An **Autonomous Agent** is an AI team member. It works on its own, makes decisions, and runs tasks without waiting for user input.
|
||||
A **Robot Agent** is an AI team member. It works on its own, makes decisions, and runs tasks without waiting for user input.
|
||||
|
||||
**Key points:**
|
||||
|
||||
|
|
@ -95,12 +95,12 @@ Uses existing `__yao.member` model (`yao/models/member.mod.yao`):
|
|||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key fields in `__yao.member` for autonomous agents:**
|
||||
**Key fields in `__yao.member` for robot agents:**
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------- | ------ | ----------------------------------------------------------- |
|
||||
| `member_type` | enum | `user` \| `robot` |
|
||||
| `autonomous_mode` | bool | Enable autonomous execution |
|
||||
| `autonomous_mode` | bool | Enable robot execution |
|
||||
| `robot_config` | JSON | Agent configuration (see section 5) |
|
||||
| `robot_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` |
|
||||
| `system_prompt` | text | Identity & role prompt |
|
||||
|
|
@ -239,14 +239,16 @@ Gathers info to help make good goals. **Clock context is key input** - Agent kno
|
|||
|
||||
```go
|
||||
type InspirationReport struct {
|
||||
Clock ClockContext // Current time context
|
||||
Summary string // What's happening
|
||||
Highlights []Highlight // Key changes
|
||||
Opportunities []Opportunity // Chances to act
|
||||
Risks []Risk // Things to watch
|
||||
WorldInsights []WorldInsight // News from outside
|
||||
Suggestions []string // What to focus on
|
||||
Clock *ClockContext `json:"clock"` // time context
|
||||
Content string `json:"content"` // markdown text for LLM
|
||||
}
|
||||
// Content is markdown like:
|
||||
// ## Summary
|
||||
// ...
|
||||
// ## Highlights
|
||||
// - [High] Sales up 50%
|
||||
// ## Opportunities / Risks / World News / Pending
|
||||
// ...
|
||||
|
||||
type ClockContext struct {
|
||||
Now time.Time // Current time
|
||||
|
|
@ -292,15 +294,18 @@ Make today's goals.
|
|||
|
||||
### 4.4 P2: Tasks
|
||||
|
||||
Breaks goals into steps:
|
||||
P2 Agent reads Goals markdown and breaks into executable tasks:
|
||||
|
||||
```go
|
||||
type Task struct {
|
||||
ID string
|
||||
GoalID string
|
||||
Description string
|
||||
ExecutorType string // "assistant" | "mcp"
|
||||
ExecutorID string
|
||||
ID string // unique task ID
|
||||
Messages []context.Message // original input (text, images, files, audio)
|
||||
GoalRef string // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource // auto | human | event
|
||||
ExecutorType ExecutorType // assistant | mcp | process
|
||||
ExecutorID string // agent ID or mcp tool name
|
||||
Args []any // arguments for executor
|
||||
Order int // execution order
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -346,13 +351,12 @@ type Config struct {
|
|||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota"`
|
||||
PrivateKB *KB `json:"private_kb"`
|
||||
SharedKB *KB `json:"shared_kb,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared KB (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning for private KB
|
||||
Resources *Resources `json:"resources"`
|
||||
Delivery *Delivery `json:"delivery"`
|
||||
Input *Input `json:"input,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Monitor *Monitor `json:"monitor,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -454,12 +458,20 @@ type Quota struct {
|
|||
}
|
||||
|
||||
// KB
|
||||
// KB - shared knowledge base (same as assistant)
|
||||
type KB struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Refs []string `json:"refs,omitempty"`
|
||||
Learn *Learn `json:"learn,omitempty"`
|
||||
Collections []string `json:"collections,omitempty"` // KB collection IDs
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// DB - shared database (same as assistant)
|
||||
type DB struct {
|
||||
Models []string `json:"models,omitempty"` // database model names
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// Learn - learning config for robot's private KB
|
||||
// Private KB auto-created: robot_{team_id}_{member_id}_kb
|
||||
type Learn struct {
|
||||
On bool `json:"on"`
|
||||
Types []string `json:"types"` // execution, feedback, insight
|
||||
|
|
@ -485,24 +497,6 @@ type Delivery struct {
|
|||
}
|
||||
|
||||
// Monitor
|
||||
type Monitor struct {
|
||||
On bool `json:"on"`
|
||||
Alerts []Alert `json:"alerts,omitempty"`
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
Name string `json:"name"`
|
||||
When string `json:"when"` // failed | timeout | error_rate
|
||||
Value float64 `json:"value"`
|
||||
Window string `json:"window"` // 1h | 24h
|
||||
Do []Action `json:"do"`
|
||||
Cooldown string `json:"cooldown"`
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Type string `json:"type"` // email | webhook | notify
|
||||
Opts map[string]interface{} `json:"opts"`
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Example
|
||||
|
|
@ -537,14 +531,13 @@ Example record in `__yao.member` table:
|
|||
"rules": ["Only access sales data"]
|
||||
},
|
||||
"quota": { "max": 2, "queue": 10, "priority": 5 },
|
||||
"private_kb": {
|
||||
"learn": {
|
||||
"on": true,
|
||||
"types": ["execution", "feedback", "insight"],
|
||||
"keep": 90
|
||||
}
|
||||
"kb": { "collections": ["sales-policies", "products"] },
|
||||
"db": { "models": ["sales", "customers"] },
|
||||
"learn": {
|
||||
"on": true,
|
||||
"types": ["execution", "feedback", "insight"],
|
||||
"keep": 90
|
||||
},
|
||||
"shared_kb": { "refs": ["sales-policies", "products"] },
|
||||
"resources": {
|
||||
"phases": {
|
||||
"inspiration": "__yao.inspiration",
|
||||
|
|
@ -663,7 +656,9 @@ stateDiagram-v2
|
|||
|
||||
### 7.1 Job System
|
||||
|
||||
Each agent = 1 Job. Each run = 1 Execution.
|
||||
**Relationship:** 1 Robot : N Executions (concurrent), 1 Execution = 1 job.Job
|
||||
|
||||
Each trigger creates a new Execution, mapped to a `job.Job` for monitoring.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
|
|
@ -807,55 +802,65 @@ type ExecutionResult struct {
|
|||
}
|
||||
|
||||
type RobotState struct {
|
||||
MemberID string // member_id from __yao.member
|
||||
Status RobotStatus // idle | working | paused | error | maintenance
|
||||
LastRun time.Time
|
||||
NextRun time.Time
|
||||
RunningID string // current execution ID if working
|
||||
MemberID string // member_id from __yao.member
|
||||
Status RobotStatus // idle | working | paused | error | maintenance
|
||||
LastRun time.Time
|
||||
NextRun time.Time
|
||||
Running int // current running execution count
|
||||
MaxRunning int // max concurrent executions (from Quota.Max)
|
||||
RunningIDs []string // list of running execution IDs
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Execution (Uses Job System)
|
||||
|
||||
No separate `autonomous_executions` table. Uses existing Job system:
|
||||
No separate `autonomous_executions` table. Uses existing Job system.
|
||||
|
||||
**Each trigger creates a new job.Job:**
|
||||
|
||||
```go
|
||||
// On robot member create - use Once/Cron/Daemon based on clock mode
|
||||
// On each trigger (clock/human/event), create a new Job
|
||||
execID := gonanoid.Must()
|
||||
j, _ := job.Once(job.GOROUTINE, map[string]interface{}{
|
||||
"job_id": "robot_" + memberID,
|
||||
"job_id": "robot_exec_" + execID, // unique per execution
|
||||
"category_id": "autonomous_robot",
|
||||
"name": member.DisplayName,
|
||||
"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)
|
||||
|
||||
// Add execution with config
|
||||
exec := &job.Execution{
|
||||
ExecutionID: gonanoid.Must(),
|
||||
JobID: j.JobID,
|
||||
Status: "queued",
|
||||
TriggerCategory: string(TriggerClock), // or TriggerHuman, TriggerEvent
|
||||
ExecutionConfig: &job.ExecutionConfig{
|
||||
Type: job.ExecutionTypeProcess,
|
||||
ProcessName: "autonomous.Execute",
|
||||
ProcessArgs: []interface{}{memberID, triggerData},
|
||||
},
|
||||
// Configure and start
|
||||
j.ExecutionConfig = &job.ExecutionConfig{
|
||||
Type: job.ExecutionTypeProcess,
|
||||
ProcessName: "robot.Execute",
|
||||
ProcessArgs: []interface{}{memberID, execID, triggerData},
|
||||
}
|
||||
job.SaveExecution(exec)
|
||||
|
||||
// Start execution
|
||||
j.Push()
|
||||
```
|
||||
|
||||
// Query history
|
||||
**Query executions for a robot:**
|
||||
|
||||
```go
|
||||
// List all executions for a robot member
|
||||
param := model.QueryParam{
|
||||
Wheres: []model.QueryWhere{{Column: "job_id", Value: j.JobID}},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "category_id", Value: "autonomous_robot"},
|
||||
{Column: "metadata->member_id", Value: memberID},
|
||||
},
|
||||
Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}},
|
||||
}
|
||||
execs, _ := job.ListExecutions(param, 1, 10)
|
||||
jobs, _ := job.ListJobs(param, 1, 10)
|
||||
```
|
||||
|
||||
**Query examples:**
|
||||
|
||||
```go
|
||||
// List robot jobs
|
||||
// List all robot jobs (all robots, all executions)
|
||||
param := model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "category_id", Value: "autonomous_robot"},
|
||||
1594
agent/robot/TECHNICAL.md
Normal file
1594
agent/robot/TECHNICAL.md
Normal file
File diff suppressed because it is too large
Load diff
653
agent/robot/TODO.md
Normal file
653
agent/robot/TODO.md
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
# Robot Agent - Implementation TODO
|
||||
|
||||
> Based on DESIGN.md and TECHNICAL.md
|
||||
> Test environment: `source yao/env.local.sh`
|
||||
> Test assistants: `yao-dev-app/assistants/robot/`
|
||||
|
||||
---
|
||||
|
||||
## Workflow: Human-AI Collaboration
|
||||
|
||||
**Important:** Follow this workflow strictly for each sub-task.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Implementation Workflow │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. AI: Implement code for current sub-task │
|
||||
│ 2. AI: Present code for review (DO NOT write tests yet) │
|
||||
│ 3. Human: Review code, provide feedback │
|
||||
│ 4. AI: Iterate based on feedback │
|
||||
│ 5. Human: Confirm "LGTM" or "Approved" │
|
||||
│ 6. AI: Write tests for the approved code │
|
||||
│ 7. Human: Review tests │
|
||||
│ 8. AI: Run tests, fix if needed │
|
||||
│ 9. Human: Confirm sub-task complete, move to next │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
| Rule | Description |
|
||||
| ------------------------ | ------------------------------------------ |
|
||||
| One sub-task at a time | Focus only on current sub-task |
|
||||
| No tests before approval | Wait for human "LGTM" before writing tests |
|
||||
| No jumping ahead | Do not implement future phases |
|
||||
| Ask if unclear | When in doubt, ask before proceeding |
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
|
||||
- Phase 1-2: Types + Skeleton (code compiles)
|
||||
- Phase 3: Complete scheduling system (Cache + Pool + Trigger + Dedup + Job), executor is stub
|
||||
- Phase 4-9: Implement executor phases one by one (P0 → P5)
|
||||
- Phase 10: API completion, end-to-end tests
|
||||
- Monitoring: Provided by Job system, no separate implementation
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Types & Interfaces ✅
|
||||
|
||||
**Goal:** Define all types, enums, interfaces. No logic, no external deps.
|
||||
|
||||
**Status:** Complete - 88.4% test coverage, all tests passing
|
||||
|
||||
### 1.1 Enums (`types/enums.go`)
|
||||
|
||||
- [x] `Phase` - execution phases (inspiration, goals, tasks, run, delivery, learning)
|
||||
- [x] `ClockMode` - clock trigger modes (times, interval, daemon)
|
||||
- [x] `TriggerType` - trigger sources (clock, human, event)
|
||||
- [x] `ExecStatus` - execution status (pending, running, completed, failed, cancelled)
|
||||
- [x] `RobotStatus` - robot status (idle, working, paused, error, maintenance)
|
||||
- [x] `InterventionAction` - human actions (task.add, goal.adjust, etc.)
|
||||
- [x] `Priority` - priority levels (high, normal, low)
|
||||
- [x] `DeliveryType` - delivery types (email, file, webhook, notify)
|
||||
- [x] `DedupResult` - dedup results (skip, merge, proceed)
|
||||
- [x] `EventSource` - event sources (webhook, database)
|
||||
- [x] `LearningType` - learning types (execution, feedback, insight)
|
||||
- [x] `TaskSource` - task sources (auto, human, event)
|
||||
- [x] `ExecutorType` - executor types (assistant, mcp, process)
|
||||
- [x] `TaskStatus` - task status (pending, running, completed, failed, skipped, cancelled)
|
||||
- [x] `InsertPosition` - insert positions (first, last, next, at)
|
||||
|
||||
### 1.2 Context (`types/context.go`)
|
||||
|
||||
- [x] `Context` struct - robot execution context
|
||||
- [x] `NewContext()` - constructor
|
||||
- [x] `UserID()`, `TeamID()` - helper methods
|
||||
|
||||
### 1.3 Config Types (`types/config.go`)
|
||||
|
||||
- [x] `Config` - main config struct
|
||||
- [x] `Triggers`, `TriggerSwitch` - trigger enable/disable
|
||||
- [x] `Clock` - clock config with validation
|
||||
- [x] `Identity` - role, duties, rules
|
||||
- [x] `Quota` - concurrency limits with defaults
|
||||
- [x] `KB`, `DB` - knowledge base and database config
|
||||
- [x] `Learn` - learning config
|
||||
- [x] `Resources`, `MCPConfig` - available agents and tools
|
||||
- [x] `Delivery` - output delivery config
|
||||
- [x] `Event` - event trigger config
|
||||
|
||||
### 1.4 Core Types (`types/robot.go`)
|
||||
|
||||
- [x] `Robot` struct - runtime robot representation
|
||||
- [x] `Robot` methods - `CanRun()`, `RunningCount()`, `AddExecution()`, `RemoveExecution()`, `GetExecution()`, `GetExecutions()`
|
||||
- [x] `Execution` struct - single execution instance
|
||||
- [x] `TriggerInput` - stored trigger input
|
||||
- [x] `CurrentState` - current executing state
|
||||
- [x] `Goals` - P1 output (markdown)
|
||||
- [x] `Task` - planned task (structured)
|
||||
- [x] `TaskResult` - task execution result
|
||||
- [x] `DeliveryResult` - delivery output
|
||||
- [x] `LearningEntry` - knowledge to save
|
||||
|
||||
### 1.5 Clock Context (`types/clock.go`)
|
||||
|
||||
- [x] `ClockContext` struct - time context for P0
|
||||
- [x] `NewClockContext()` - constructor
|
||||
|
||||
### 1.6 Inspiration (`types/inspiration.go`)
|
||||
|
||||
- [x] `InspirationReport` struct - P0 output
|
||||
|
||||
### 1.7 Request/Response (`types/request.go`)
|
||||
|
||||
- [x] `InterveneRequest` - human intervention request
|
||||
- [x] `EventRequest` - event trigger request
|
||||
- [x] `ExecutionResult` - trigger result
|
||||
- [x] `RobotState` - robot status query result
|
||||
|
||||
### 1.8 Interfaces (`types/interfaces.go`)
|
||||
|
||||
- [x] `Manager` interface
|
||||
- [x] `Executor` interface
|
||||
- [x] `Pool` interface
|
||||
- [x] `Cache` interface
|
||||
- [x] `Dedup` interface
|
||||
- [x] `Store` interface
|
||||
|
||||
### 1.9 Errors (`types/errors.go`)
|
||||
|
||||
- [x] Config errors
|
||||
- [x] Runtime errors
|
||||
- [x] Phase errors
|
||||
|
||||
### 1.10 Tests
|
||||
|
||||
- [x] `types/enums_test.go` - enum validation
|
||||
- [x] `types/config_test.go` - config validation
|
||||
- [x] `types/clock_test.go` - clock context creation
|
||||
- [x] `types/robot_test.go` - robot methods
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Skeleton Implementation ✅
|
||||
|
||||
**Goal:** Create all packages with empty/stub implementations. Code compiles.
|
||||
|
||||
**Status:** Complete - All packages compile successfully, no circular dependencies
|
||||
|
||||
### 2.1 Utils (`utils/`) ✅
|
||||
|
||||
- [x] `utils/convert.go` - JSON, map, struct conversions (implement)
|
||||
- [x] `utils/time.go` - time parsing, formatting, timezone (implement)
|
||||
- [x] `utils/id.go` - ID generation (nanoid) (implement)
|
||||
- [x] `utils/validate.go` - validation helpers (implement)
|
||||
- [x] Test: `utils/utils_test.go`
|
||||
|
||||
### 2.2 Package Skeletons ✅ (stubs only, implemented in Phase 3)
|
||||
|
||||
Create empty structs and stub methods that return nil/empty/success:
|
||||
|
||||
- [x] `cache/cache.go` - Cache struct, stub methods
|
||||
- [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()`
|
||||
- [x] `manager/manager.go` - Manager struct, stub methods
|
||||
|
||||
### 2.3 API Skeletons ✅
|
||||
|
||||
- [x] `api/api.go` - Go API facade (all function signatures, return errors)
|
||||
- [x] `api/process.go` - Yao Process registration (all processes, return errors)
|
||||
- [x] `api/jsapi.go` - JSAPI registration (all methods, return errors)
|
||||
|
||||
### 2.4 Root ✅
|
||||
|
||||
- [x] `robot.go` - package entry
|
||||
- [x] `Init()` - placeholder
|
||||
- [x] `Shutdown()` - placeholder
|
||||
|
||||
### 2.5 Compile Test ✅
|
||||
|
||||
- [x] All packages compile without errors
|
||||
- [x] All imports resolve correctly
|
||||
- [x] No circular dependencies
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Complete Scheduling System
|
||||
|
||||
**Goal:** Implement complete scheduling system. Executor is stub (simulates success).
|
||||
|
||||
This phase delivers a fully working scheduling pipeline:
|
||||
|
||||
```
|
||||
Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) → Job
|
||||
```
|
||||
|
||||
### ✅ 3.1 Cache Implementation (COMPLETE)
|
||||
|
||||
- [x] `cache/cache.go` - Cache struct with thread-safe map
|
||||
- [x] `cache/load.go` - load robots from `__yao.member` where `member_type='robot'` and `autonomous_mode=true`
|
||||
- [x] Implemented pagination (100 robots per page)
|
||||
- [x] Configurable model name via `SetMemberModel()`
|
||||
- [x] `cache/refresh.go` - refresh single robot, periodic full refresh (every hour)
|
||||
- [x] Test: load/refresh with real DB
|
||||
- [x] Created comprehensive integration tests with real database
|
||||
- [x] Tests cover Load, LoadByID, Refresh, ListByTeam, GetByStatus
|
||||
- [x] All tests passing with proper cleanup
|
||||
|
||||
### ✅ 3.2 Pool Implementation (COMPLETE)
|
||||
|
||||
- [x] `pool/pool.go` - worker pool with configurable size (global limit)
|
||||
- [x] Default config: 10 workers, 100 queue size
|
||||
- [x] Configurable via `pool.NewWithConfig()`
|
||||
- [x] `pool/queue.go` - priority queue (sorted by: robot priority, trigger type, wait time)
|
||||
- [x] Two-level limit: global queue + per-robot queue
|
||||
- [x] Priority: Robot Priority × 1000 + Trigger Priority × 100
|
||||
- [x] `pool/worker.go` - worker goroutines, dispatch to executor
|
||||
- [x] Non-blocking quota check with re-enqueue
|
||||
- [x] Graceful shutdown support
|
||||
- [x] Test: submit jobs, verify execution order, verify concurrency limits
|
||||
- [x] 15 test cases covering all edge cases
|
||||
- [x] All tests passing
|
||||
|
||||
### 3.3 Trigger Implementation
|
||||
|
||||
- [ ] `trigger/trigger.go` - trigger dispatcher (routes to clock/intervene/event)
|
||||
- [ ] `trigger/clock.go` - clock trigger
|
||||
- [ ] `times` mode: match specific times (09:00, 14:00)
|
||||
- [ ] `interval` mode: run every X duration (30m, 1h)
|
||||
- [ ] `daemon` mode: restart immediately after completion
|
||||
- [ ] Timezone handling
|
||||
- [ ] `trigger/intervene.go` - human intervention
|
||||
- [ ] Parse action (task.add, goal.adjust, etc.)
|
||||
- [ ] Build TriggerInput with Messages
|
||||
- [ ] `trigger/event.go` - event handling
|
||||
- [ ] Webhook event dispatch
|
||||
- [ ] Database change event dispatch
|
||||
- [ ] `trigger/control.go` - execution control
|
||||
- [ ] Pause execution
|
||||
- [ ] Resume execution
|
||||
- [ ] Cancel/Stop execution
|
||||
- [ ] Test: clock matching (all modes), intervention handling, event dispatch
|
||||
|
||||
### 3.4 Dedup Implementation
|
||||
|
||||
- [ ] `dedup/dedup.go` - Dedup struct
|
||||
- [ ] `dedup/fast.go` - fast in-memory time-window dedup
|
||||
- [ ] Key: `memberID:triggerType:window`
|
||||
- [ ] Check before submit
|
||||
- [ ] Mark after submit
|
||||
- [ ] Test: dedup check/mark, window expiry
|
||||
|
||||
### 3.5 Job Integration
|
||||
|
||||
- [ ] `job/job.go` - create job
|
||||
- [ ] `job_id`: `robot_exec_{execID}`
|
||||
- [ ] `category_id`: `autonomous_robot`
|
||||
- [ ] Metadata: member_id, team_id, trigger_type, exec_id
|
||||
- [ ] `job/execution.go` - execution lifecycle
|
||||
- [ ] Create execution on trigger
|
||||
- [ ] Update status on phase change
|
||||
- [ ] Complete/fail on finish
|
||||
- [ ] `job/log.go` - write phase logs
|
||||
- [ ] Log phase start/end
|
||||
- [ ] Log errors
|
||||
- [ ] Test: job creation, execution tracking, log writing
|
||||
|
||||
### 3.6 Manager Implementation
|
||||
|
||||
- [ ] `manager/manager.go` - Manager struct
|
||||
- [ ] `Start()` - start ticker goroutine, start pool
|
||||
- [ ] `Stop()` - graceful shutdown (wait for running, drain queue)
|
||||
- [ ] `Tick()` - main loop:
|
||||
1. Get all cached robots
|
||||
2. For each robot with clock trigger enabled
|
||||
3. Check if should execute (schedule match + dedup)
|
||||
4. Submit to pool
|
||||
- [ ] Test: manager start/stop, tick cycle
|
||||
|
||||
### 3.7 Executor Stub
|
||||
|
||||
- [ ] `executor/executor.go` - stub implementation
|
||||
- [ ] `Execute()` - simulate full execution
|
||||
1. Create Execution record
|
||||
2. Update phase: P0 → P1 → P2 → P3 → P4 → P5
|
||||
3. Sleep briefly between phases (simulate work)
|
||||
4. Return success with mock data
|
||||
- [ ] Test: verify stub called, verify phase progression
|
||||
|
||||
### 3.8 Integration Test (End-to-End Scheduling)
|
||||
|
||||
- [ ] Create test robot in `__yao.member` with clock config
|
||||
- [ ] Start manager
|
||||
- [ ] Wait for clock trigger
|
||||
- [ ] Verify:
|
||||
- [ ] Robot loaded to cache
|
||||
- [ ] Clock trigger matched
|
||||
- [ ] Dedup checked
|
||||
- [ ] Job submitted to pool
|
||||
- [ ] Worker picked up job
|
||||
- [ ] Executor stub called
|
||||
- [ ] Job execution recorded
|
||||
- [ ] Logs written
|
||||
- [ ] Test human intervention trigger
|
||||
- [ ] Test event trigger
|
||||
- [ ] Test concurrent executions (multiple robots)
|
||||
- [ ] Test quota enforcement (per-robot limit)
|
||||
- [ ] Test pause/resume/stop
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Executor - P0 Inspiration
|
||||
|
||||
**Goal:** Implement P0 (Inspiration Agent). Clock trigger → P0 → stub P1-P5.
|
||||
|
||||
### 4.1 Test Assistant Setup
|
||||
|
||||
Create `yao-dev-app/assistants/robot/` directory:
|
||||
|
||||
- [ ] `inspiration/package.yao` - Inspiration Agent config
|
||||
- [ ] `inspiration/prompts.yml` - P0 prompts
|
||||
- [ ] `inspiration/src/index.ts` - hooks if needed
|
||||
|
||||
### 4.2 P0 Implementation
|
||||
|
||||
- [ ] `executor/inspiration.go` - build prompt with `ClockContext`
|
||||
- [ ] `executor/inspiration.go` - call Inspiration Agent
|
||||
- [ ] `executor/inspiration.go` - parse response to `InspirationReport`
|
||||
- [ ] `executor/prompt.go` - `BuildInspirationPrompt()`
|
||||
|
||||
### 4.3 Tests
|
||||
|
||||
- [ ] `executor/inspiration_test.go` - P0 with real LLM call
|
||||
- [ ] Verify: clock context in prompt
|
||||
- [ ] Verify: markdown report generated
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Executor - P1 Goals
|
||||
|
||||
**Goal:** Implement P1 (Goal Generation Agent). P0 → P1 → stub P2-P5.
|
||||
|
||||
### 5.1 Test Assistant Setup
|
||||
|
||||
- [ ] `goals/package.yao` - Goal Generation Agent config
|
||||
- [ ] `goals/prompts.yml` - P1 prompts
|
||||
|
||||
### 5.2 P1 Implementation
|
||||
|
||||
- [ ] `executor/goals.go` - build prompt with inspiration report
|
||||
- [ ] `executor/goals.go` - call Goal Agent
|
||||
- [ ] `executor/goals.go` - parse response to `Goals` (markdown)
|
||||
- [ ] `executor/prompt.go` - `BuildGoalsPrompt()`
|
||||
|
||||
### 5.3 Tests
|
||||
|
||||
- [ ] `executor/goals_test.go` - P1 with real LLM call
|
||||
- [ ] Verify: inspiration report in prompt
|
||||
- [ ] Verify: goals markdown generated
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Executor - P2 Tasks
|
||||
|
||||
**Goal:** Implement P2 (Task Planning Agent). P1 → P2 → stub P3-P5.
|
||||
|
||||
### 6.1 Test Assistant Setup
|
||||
|
||||
- [ ] `tasks/package.yao` - Task Planning Agent config
|
||||
- [ ] `tasks/prompts.yml` - P2 prompts
|
||||
|
||||
### 6.2 P2 Implementation
|
||||
|
||||
- [ ] `executor/tasks.go` - build prompt with goals
|
||||
- [ ] `executor/tasks.go` - call Task Agent
|
||||
- [ ] `executor/tasks.go` - parse response to `[]Task` (structured)
|
||||
- [ ] `executor/prompt.go` - `BuildTasksPrompt()`
|
||||
|
||||
### 6.3 Tests
|
||||
|
||||
- [ ] `executor/tasks_test.go` - P2 with real LLM call
|
||||
- [ ] Verify: goals in prompt
|
||||
- [ ] Verify: structured tasks generated
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Executor - P3 Run
|
||||
|
||||
**Goal:** Implement P3 (Task Execution). P2 → P3 → stub P4-P5.
|
||||
|
||||
### 7.1 Implementation
|
||||
|
||||
- [ ] `executor/run.go` - iterate tasks
|
||||
- [ ] `executor/run.go` - call executor (assistant/mcp/process)
|
||||
- [ ] `executor/run.go` - collect results
|
||||
- [ ] `executor/agent.go` - unified agent call method
|
||||
|
||||
### 7.2 Validation Agent Setup
|
||||
|
||||
- [ ] `validation/package.yao` - Validation Agent config
|
||||
- [ ] `validation/prompts.yml` - validation prompts
|
||||
|
||||
### 7.3 Tests
|
||||
|
||||
- [ ] `executor/run_test.go` - P3 with real agent calls
|
||||
- [ ] Verify: tasks executed in order
|
||||
- [ ] Verify: results collected
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Executor - P4 Delivery
|
||||
|
||||
**Goal:** Implement P4 (Delivery). P3 → P4 → stub P5.
|
||||
|
||||
### 8.1 Test Assistant Setup
|
||||
|
||||
- [ ] `delivery/package.yao` - Delivery Agent config
|
||||
- [ ] `delivery/prompts.yml` - delivery prompts
|
||||
|
||||
### 8.2 Implementation
|
||||
|
||||
- [ ] `executor/delivery.go` - build delivery content
|
||||
- [ ] `executor/delivery.go` - send via configured channel (email/file/webhook/notify)
|
||||
|
||||
### 8.3 Tests
|
||||
|
||||
- [ ] `executor/delivery_test.go` - P4 delivery
|
||||
- [ ] Verify: delivery sent (mock or real)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Executor - P5 Learning
|
||||
|
||||
**Goal:** Implement P5 (Learning). Full execution flow complete.
|
||||
|
||||
### 9.1 Test Assistant Setup
|
||||
|
||||
- [ ] `learning/package.yao` - Learning Agent config
|
||||
- [ ] `learning/prompts.yml` - learning prompts
|
||||
|
||||
### 9.2 Store Implementation
|
||||
|
||||
- [ ] `store/kb.go` - KB operations (create, save, search)
|
||||
- [ ] `store/learning.go` - save learning entries to private KB
|
||||
|
||||
### 9.3 Implementation
|
||||
|
||||
- [ ] `executor/learning.go` - extract learnings from execution
|
||||
- [ ] `executor/learning.go` - call Learning Agent
|
||||
- [ ] `executor/learning.go` - save to private KB
|
||||
|
||||
### 9.4 Tests
|
||||
|
||||
- [ ] `executor/learning_test.go` - P5 learning
|
||||
- [ ] Verify: learnings saved to KB
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: API & Integration
|
||||
|
||||
**Goal:** Complete API implementation, end-to-end tests.
|
||||
|
||||
### 10.1 API Implementation
|
||||
|
||||
- [ ] `api/api.go` - implement all Go API functions
|
||||
- [ ] `api/process.go` - implement all Process handlers
|
||||
- [ ] `api/jsapi.go` - implement JSAPI
|
||||
|
||||
### 10.2 End-to-End Tests
|
||||
|
||||
- [ ] Full clock trigger flow (P0 → P5)
|
||||
- [ ] Human intervention flow (P1 → P5)
|
||||
- [ ] Event trigger flow (P1 → P5)
|
||||
- [ ] Concurrent execution test
|
||||
- [ ] Pause/Resume/Stop test
|
||||
|
||||
### 10.3 Integration with OpenAPI
|
||||
|
||||
- [ ] HTTP endpoints for human intervention
|
||||
- [ ] Webhook endpoints for events
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Advanced Features
|
||||
|
||||
**Goal:** Implement semantic dedup, plan queue.
|
||||
|
||||
### 11.1 Semantic Dedup
|
||||
|
||||
- [ ] `dedup/semantic.go` - call Dedup Agent for goal/task level dedup
|
||||
- [ ] Dedup Agent setup (`assistants/robot/dedup/`)
|
||||
- [ ] Test: semantic dedup with real LLM
|
||||
|
||||
### 11.2 Plan Queue
|
||||
|
||||
- [ ] `plan/plan.go` - plan queue implementation
|
||||
- [ ] Store planned tasks/goals
|
||||
- [ ] Execute at next cycle or specified time
|
||||
- [ ] `plan/schedule.go` - schedule for later
|
||||
- [ ] Test: plan queue operations
|
||||
|
||||
> **Note:** Monitoring is provided by Job system (Activity Monitor UI). No separate implementation needed.
|
||||
|
||||
---
|
||||
|
||||
## Test Assistants Structure
|
||||
|
||||
```
|
||||
yao-dev-app/assistants/robot/
|
||||
├── inspiration/ # P0: Inspiration Agent
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── goals/ # P1: Goal Generation Agent
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── tasks/ # P2: Task Planning Agent
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── validation/ # P3: Validation Agent
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── delivery/ # P4: Delivery Agent
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── learning/ # P5: Learning Agent
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
└── dedup/ # Deduplication Agent
|
||||
├── package.yao
|
||||
└── prompts.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### Test Environment Setup
|
||||
|
||||
1. **Environment Variables:** Run `source yao/env.local.sh` before tests
|
||||
2. **Test Preparation:** Use `testutils.Prepare(t)` to load config, KB, and agents
|
||||
|
||||
```go
|
||||
package robot_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
func TestExample(t *testing.T) {
|
||||
// Load environment config (from YAO_TEST_APPLICATION)
|
||||
// This loads: config, connectors, KB, agents, models, etc.
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Your test code here
|
||||
}
|
||||
```
|
||||
|
||||
### Test Conventions
|
||||
|
||||
1. **Black-box Tests:** All tests in `*_test` package (external package)
|
||||
2. **Real LLM Calls:** Use `gpt-4o` or `deepseek` connectors for agent tests
|
||||
3. **Incremental:** Each phase builds on previous, all tests must pass before next phase
|
||||
4. **No Skip:** Do NOT use `t.Skip()` except for `testing.Short()` (CI mode)
|
||||
5. **Must Assert:** Every test MUST have result validation assertions
|
||||
|
||||
```go
|
||||
func TestWithLLM(t *testing.T) {
|
||||
// Only allowed Skip: testing.Short() for CI
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Your test code...
|
||||
result, err := SomeFunction()
|
||||
|
||||
// MUST have assertions - no empty tests!
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, expected, result.Field)
|
||||
}
|
||||
```
|
||||
|
||||
### Test Rules
|
||||
|
||||
| Rule | Description |
|
||||
| ----------------- | ----------------------------------------- |
|
||||
| No arbitrary Skip | Only `testing.Short()` skip allowed |
|
||||
| Must assert | Every test must validate results |
|
||||
| No empty tests | Tests without assertions will fail review |
|
||||
| Real calls | LLM tests use real API calls, not mocks |
|
||||
|
||||
### Key Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | ------------------------------- |
|
||||
| `YAO_TEST_APPLICATION` | Test app path (`yao-dev-app`) |
|
||||
| `OPENAI_TEST_KEY` | OpenAI API key |
|
||||
| `DEEPSEEK_API_KEY` | DeepSeek API key |
|
||||
| `YAO_DB_DRIVER` | Database driver (mysql/sqlite3) |
|
||||
| `YAO_DB_PRIMARY` | Database connection string |
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
| Phase | Status | Description |
|
||||
| --------------------- | ------ | ---------------------------------------------------- |
|
||||
| 1. Types & Interfaces | ✅ | All types, enums, interfaces |
|
||||
| 2. Skeleton | ✅ | Empty stubs, code compiles |
|
||||
| 3. Scheduling System | ⬜ | Cache + Pool + Trigger + Dedup + Job (executor stub) |
|
||||
| 4. P0 Inspiration | ⬜ | Inspiration Agent integration |
|
||||
| 5. P1 Goals | ⬜ | Goal Generation Agent integration |
|
||||
| 6. P2 Tasks | ⬜ | Task Planning Agent integration |
|
||||
| 7. P3 Run | ⬜ | Task execution (assistant/mcp/process) |
|
||||
| 8. P4 Delivery | ⬜ | Output delivery (email/file/webhook/notify) |
|
||||
| 9. P5 Learning | ⬜ | Learning Agent + KB save |
|
||||
| 10. API & Integration | ⬜ | Complete API, end-to-end tests |
|
||||
| 11. Advanced | ⬜ | Semantic dedup, plan queue |
|
||||
|
||||
Legend: ⬜ Not started | 🟡 In progress | ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Setup environment
|
||||
source yao/env.local.sh
|
||||
|
||||
# Run all robot tests
|
||||
go test -v ./agent/robot/...
|
||||
|
||||
# Run specific phase tests
|
||||
go test -v ./agent/robot/types/...
|
||||
go test -v ./agent/robot/cache/...
|
||||
go test -v ./agent/robot/pool/...
|
||||
go test -v ./agent/robot/executor/...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./agent/robot/...
|
||||
```
|
||||
200
agent/robot/api/api.go
Normal file
200
agent/robot/api/api.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ==================== CRUD ====================
|
||||
|
||||
// Get returns a robot by member ID
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func Get(ctx *types.Context, memberID string) (*types.Robot, error) {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// List returns robots with pagination and filtering
|
||||
// Stub: returns empty result (will be implemented in Phase 10)
|
||||
func List(ctx *types.Context, query *ListQuery) (*ListResult, error) {
|
||||
return &ListResult{
|
||||
Data: []*types.Robot{},
|
||||
Total: 0,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create creates a new robot member
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func Create(ctx *types.Context, teamID string, req *CreateRequest) (*types.Robot, error) {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// Update updates robot config
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func Update(ctx *types.Context, memberID string, req *UpdateRequest) (*types.Robot, error) {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// Remove deletes a robot member
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func Remove(ctx *types.Context, memberID string) error {
|
||||
return types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// ==================== Status ====================
|
||||
|
||||
// Status returns current robot runtime state
|
||||
// Stub: returns empty state (will be implemented in Phase 10)
|
||||
func Status(ctx *types.Context, memberID string) (*RobotState, error) {
|
||||
return &RobotState{
|
||||
MemberID: memberID,
|
||||
Status: types.RobotIdle,
|
||||
Running: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates robot status (idle, paused, etc.)
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func UpdateStatus(ctx *types.Context, memberID string, status types.RobotStatus) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== Trigger ====================
|
||||
|
||||
// Trigger starts execution with specified trigger type and request
|
||||
// Stub: returns empty result (will be implemented in Phase 10)
|
||||
func Trigger(ctx *types.Context, memberID string, req *TriggerRequest) (*TriggerResult, error) {
|
||||
return &TriggerResult{
|
||||
Accepted: false,
|
||||
Message: "not implemented",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ==================== Execution ====================
|
||||
|
||||
// GetExecutions returns execution history
|
||||
// Stub: returns empty result (will be implemented in Phase 10)
|
||||
func GetExecutions(ctx *types.Context, memberID string, query *ExecutionQuery) (*ExecutionResult, error) {
|
||||
return &ExecutionResult{
|
||||
Data: []*types.Execution{},
|
||||
Total: 0,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetExecution returns a specific execution by ID
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func GetExecution(ctx *types.Context, execID string) (*types.Execution, error) {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
// Pause pauses a running execution
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func Pause(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume resumes a paused execution
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func Resume(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops a running execution
|
||||
// Stub: returns nil (will be implemented in Phase 10)
|
||||
func Stop(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== API Types ====================
|
||||
|
||||
// CreateRequest - request for Create()
|
||||
type CreateRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
Config *types.Config `json:"robot_config"`
|
||||
}
|
||||
|
||||
// UpdateRequest - request for Update()
|
||||
type UpdateRequest struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
Config *types.Config `json:"robot_config,omitempty"`
|
||||
}
|
||||
|
||||
// ListQuery - query options for List()
|
||||
type ListQuery struct {
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
Status types.RobotStatus `json:"status,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ClockMode types.ClockMode `json:"clock_mode,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// ListResult - result of List()
|
||||
type ListResult struct {
|
||||
Data []*types.Robot `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
|
||||
// RobotState - runtime state from Status()
|
||||
type RobotState struct {
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status types.RobotStatus `json:"status"`
|
||||
Running int `json:"running"`
|
||||
MaxRunning int `json:"max_running"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerRequest - request for Trigger()
|
||||
type TriggerRequest struct {
|
||||
Type types.TriggerType `json:"type"` // human | event
|
||||
|
||||
// Human intervention fields (when Type = human)
|
||||
Action types.InterventionAction `json:"action,omitempty"`
|
||||
Messages []interface{} `json:"messages,omitempty"` // context.Message
|
||||
PlanAt *time.Time `json:"plan_at,omitempty"`
|
||||
InsertPosition types.InsertPosition `json:"insert_at,omitempty"`
|
||||
AtIndex int `json:"at_index,omitempty"`
|
||||
|
||||
// Event fields (when Type = event)
|
||||
Source types.EventSource `json:"source,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerResult - result of Trigger()
|
||||
type TriggerResult struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Queued bool `json:"queued"`
|
||||
Execution *types.Execution `json:"execution,omitempty"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionQuery - query options for GetExecutions()
|
||||
type ExecutionQuery struct {
|
||||
Status types.ExecStatus `json:"status,omitempty"`
|
||||
Trigger types.TriggerType `json:"trigger,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionResult - result of GetExecutions()
|
||||
type ExecutionResult struct {
|
||||
Data []*types.Execution `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
18
agent/robot/api/jsapi.go
Normal file
18
agent/robot/api/jsapi.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package api
|
||||
|
||||
// JSAPI for V8 Runtime
|
||||
// Stub: all JSAPI methods return errors (will be implemented in Phase 10)
|
||||
|
||||
// This file defines the JavaScript API structure
|
||||
// Implementation will be added in Phase 10
|
||||
|
||||
// ExportFunction exports the Robot constructor to V8
|
||||
// Stub: not implemented yet (will be implemented in Phase 10)
|
||||
|
||||
// ExportObject exports the robot global object to V8
|
||||
// Stub: not implemented yet (will be implemented in Phase 10)
|
||||
|
||||
// The actual V8 integration will be implemented in Phase 10 following the pattern:
|
||||
// - Robot constructor: new Robot(memberID)
|
||||
// - Global robot object: robot.List(), robot.Get(), etc.
|
||||
// - Instance methods: bot.Status(), bot.Trigger(), etc.
|
||||
82
agent/robot/api/process.go
Normal file
82
agent/robot/api/process.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package api
|
||||
|
||||
// Process API for Yao Process system
|
||||
// Stub: all process handlers return errors (will be implemented in Phase 10)
|
||||
|
||||
// processGet handles robot.Get process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processGet(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processList handles robot.List process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processList(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processCreate handles robot.Create process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processCreate(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processUpdate handles robot.Update process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processUpdate(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processRemove handles robot.Remove process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processRemove(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processStatus handles robot.Status process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processStatus(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processUpdateStatus handles robot.UpdateStatus process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processUpdateStatus(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processTrigger handles robot.Trigger process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processTrigger(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processExecutions handles robot.Executions process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processExecutions(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processExecution handles robot.Execution process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processExecution(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processPause handles robot.Pause process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processPause(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processResume handles robot.Resume process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processResume(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processStop handles robot.Stop process
|
||||
// Stub: returns error (will be implemented in Phase 10)
|
||||
func processStop(args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
99
agent/robot/cache/cache.go
vendored
Normal file
99
agent/robot/cache/cache.go
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Cache implements types.Cache interface
|
||||
// Thread-safe in-memory cache for Robot instances
|
||||
type Cache struct {
|
||||
robots map[string]*types.Robot // memberID -> Robot
|
||||
byTeam map[string][]string // teamID -> memberIDs
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new cache instance
|
||||
func New() *Cache {
|
||||
return &Cache{
|
||||
robots: make(map[string]*types.Robot),
|
||||
byTeam: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a robot by member ID
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (c *Cache) Get(memberID string) *types.Robot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.robots[memberID]
|
||||
}
|
||||
|
||||
// List returns all robots for a team
|
||||
func (c *Cache) List(teamID string) []*types.Robot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
memberIDs := c.byTeam[teamID]
|
||||
robots := make([]*types.Robot, 0, len(memberIDs))
|
||||
for _, memberID := range memberIDs {
|
||||
if robot := c.robots[memberID]; robot != nil {
|
||||
robots = append(robots, robot)
|
||||
}
|
||||
}
|
||||
return robots
|
||||
}
|
||||
|
||||
// Note: Refresh is implemented in refresh.go
|
||||
|
||||
// Add adds or updates a robot in cache
|
||||
func (c *Cache) Add(robot *types.Robot) {
|
||||
if robot == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.robots[robot.MemberID] = robot
|
||||
|
||||
// Update team index
|
||||
if _, exists := c.byTeam[robot.TeamID]; !exists {
|
||||
c.byTeam[robot.TeamID] = []string{}
|
||||
}
|
||||
|
||||
// Check if member ID already in team list
|
||||
found := false
|
||||
for _, id := range c.byTeam[robot.TeamID] {
|
||||
if id == robot.MemberID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.byTeam[robot.TeamID] = append(c.byTeam[robot.TeamID], robot.MemberID)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove removes a robot from cache
|
||||
func (c *Cache) Remove(memberID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
robot := c.robots[memberID]
|
||||
if robot == nil {
|
||||
return
|
||||
}
|
||||
|
||||
delete(c.robots, memberID)
|
||||
|
||||
// Remove from team index
|
||||
teamMembers := c.byTeam[robot.TeamID]
|
||||
for i, id := range teamMembers {
|
||||
if id == memberID {
|
||||
c.byTeam[robot.TeamID] = append(teamMembers[:i], teamMembers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
481
agent/robot/cache/cache_test.go
vendored
Normal file
481
agent/robot/cache/cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/xun/capsule"
|
||||
"github.com/yaoapp/yao/agent/robot/cache"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestCacheLoad tests loading all active robots from database
|
||||
func TestCacheLoad(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Clean up any existing test data first
|
||||
cleanupTestRobots(t)
|
||||
|
||||
// Create test robots in database
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load all robots
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Count should be at least 2 (may have other robots in DB)
|
||||
count := c.Count()
|
||||
assert.GreaterOrEqual(t, count, 2, "Should load at least 2 active autonomous robots")
|
||||
|
||||
// Verify first robot
|
||||
robot1 := c.Get("robot_test_sales_001")
|
||||
assert.NotNil(t, robot1, "Sales bot should be loaded")
|
||||
if robot1 == nil {
|
||||
t.Fatal("robot_test_sales_001 not found in cache")
|
||||
}
|
||||
assert.Equal(t, "robot_test_sales_001", robot1.MemberID)
|
||||
assert.Equal(t, "team_test_cache_001", robot1.TeamID)
|
||||
assert.Equal(t, "Test Sales Bot", robot1.DisplayName)
|
||||
assert.Equal(t, types.RobotIdle, robot1.Status)
|
||||
assert.True(t, robot1.AutonomousMode)
|
||||
assert.NotNil(t, robot1.Config, "Robot config should be parsed")
|
||||
assert.NotNil(t, robot1.Config.Identity, "Identity should be parsed")
|
||||
assert.Equal(t, "Sales Manager", robot1.Config.Identity.Role)
|
||||
assert.Equal(t, 3, robot1.Config.Quota.GetMax())
|
||||
|
||||
// Verify second robot
|
||||
robot2 := c.Get("robot_test_support_002")
|
||||
assert.NotNil(t, robot2, "Support bot should be loaded")
|
||||
assert.Equal(t, "robot_test_support_002", robot2.MemberID)
|
||||
assert.Equal(t, "Test Support Bot", robot2.DisplayName)
|
||||
assert.NotNil(t, robot2.Config)
|
||||
assert.Equal(t, "Customer Support", robot2.Config.Identity.Role)
|
||||
assert.Equal(t, 2, robot2.Config.Quota.GetMax())
|
||||
|
||||
// Verify inactive robot is not loaded
|
||||
robot3 := c.Get("robot_test_inactive_003")
|
||||
assert.Nil(t, robot3, "Inactive robot should not be loaded")
|
||||
}
|
||||
|
||||
// TestCacheLoadByID tests loading a single robot by member ID
|
||||
func TestCacheLoadByID(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("load existing robot", func(t *testing.T) {
|
||||
robot, err := c.LoadByID(ctx, "robot_test_sales_001")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, robot)
|
||||
assert.Equal(t, "robot_test_sales_001", robot.MemberID)
|
||||
assert.Equal(t, "Test Sales Bot", robot.DisplayName)
|
||||
assert.NotNil(t, robot.Config)
|
||||
})
|
||||
|
||||
t.Run("load non-existent robot", func(t *testing.T) {
|
||||
robot, err := c.LoadByID(ctx, "robot_nonexistent")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrRobotNotFound, err)
|
||||
assert.Nil(t, robot)
|
||||
})
|
||||
|
||||
t.Run("load inactive robot by ID", func(t *testing.T) {
|
||||
// LoadByID doesn't filter by status, so it should load
|
||||
robot, err := c.LoadByID(ctx, "robot_test_inactive_003")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, robot)
|
||||
assert.Equal(t, "robot_test_inactive_003", robot.MemberID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCacheRefresh tests refreshing a single robot from database
|
||||
func TestCacheRefresh(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load initial data
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("refresh existing robot", func(t *testing.T) {
|
||||
err := c.Refresh(ctx, "robot_test_sales_001")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Robot should still be in cache
|
||||
robot := c.Get("robot_test_sales_001")
|
||||
assert.NotNil(t, robot)
|
||||
})
|
||||
|
||||
t.Run("refresh removes non-existent robot", func(t *testing.T) {
|
||||
// Add a fake robot to cache
|
||||
c.Add(&types.Robot{MemberID: "robot_test_fake", TeamID: "team_test_cache_001"})
|
||||
assert.NotNil(t, c.Get("robot_test_fake"))
|
||||
|
||||
// Refresh should remove it
|
||||
err := c.Refresh(ctx, "robot_test_fake")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, c.Get("robot_test_fake"), "Non-existent robot should be removed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCacheListByTeam tests listing robots by team
|
||||
func TestCacheListByTeam(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load all robots
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// List robots by team
|
||||
robots := c.List("team_test_cache_001")
|
||||
assert.Len(t, robots, 2, "Should have 2 robots in team_test_cache_001")
|
||||
|
||||
// List robots for non-existent team
|
||||
robots = c.List("team_nonexistent")
|
||||
assert.Len(t, robots, 0, "Non-existent team should have no robots")
|
||||
}
|
||||
|
||||
// TestCacheGetByStatus tests getting robots by status
|
||||
func TestCacheGetByStatus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load all robots
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get idle robots (may have others in DB)
|
||||
idle := c.GetIdle()
|
||||
assert.GreaterOrEqual(t, len(idle), 2, "Should have at least 2 idle robots")
|
||||
|
||||
// Verify our test robots are not working
|
||||
testRobot1 := c.Get("robot_test_sales_001")
|
||||
testRobot2 := c.Get("robot_test_support_002")
|
||||
assert.Equal(t, types.RobotIdle, testRobot1.Status, "Test robot 1 should be idle")
|
||||
assert.Equal(t, types.RobotIdle, testRobot2.Status, "Test robot 2 should be idle")
|
||||
}
|
||||
|
||||
// TestCacheAutoRefresh tests auto-refresh functionality and goroutine leak prevention
|
||||
func TestCacheAutoRefresh(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cleanupTestRobots(t)
|
||||
setupTestRobots(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
c := cache.New()
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Load initial data
|
||||
err := c.Load(ctx)
|
||||
assert.NoError(t, err)
|
||||
initialCount := c.Count()
|
||||
|
||||
t.Run("start and stop auto-refresh", func(t *testing.T) {
|
||||
// Record initial goroutine count
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
initialGoroutines := runtime.NumGoroutine()
|
||||
|
||||
// Start auto-refresh with short interval
|
||||
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
|
||||
c.StartAutoRefresh(ctx, config)
|
||||
|
||||
// Wait a bit to let it run (should trigger at least 2 refreshes)
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
// Stop auto-refresh
|
||||
c.StopAutoRefresh()
|
||||
|
||||
// Wait for goroutine to exit
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Check for goroutine leak
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
|
||||
"Should not leak goroutines after stop (initial: %d, final: %d)",
|
||||
initialGoroutines, finalGoroutines)
|
||||
|
||||
// Should still have robots
|
||||
assert.GreaterOrEqual(t, c.Count(), initialCount)
|
||||
})
|
||||
|
||||
t.Run("multiple start calls should not leak goroutines", func(t *testing.T) {
|
||||
// Record initial goroutine count
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
initialGoroutines := runtime.NumGoroutine()
|
||||
|
||||
// Start multiple times without stopping
|
||||
// This should not create multiple goroutines or ticker leaks
|
||||
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
|
||||
|
||||
c.StartAutoRefresh(ctx, config)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
c.StartAutoRefresh(ctx, config) // Should stop previous one
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
c.StartAutoRefresh(ctx, config) // Should stop previous one
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// After multiple starts, should only have 1 goroutine running
|
||||
afterStartsGoroutines := runtime.NumGoroutine()
|
||||
assert.LessOrEqual(t, afterStartsGoroutines, initialGoroutines+2,
|
||||
"Multiple starts should not accumulate goroutines (initial: %d, after starts: %d)",
|
||||
initialGoroutines, afterStartsGoroutines)
|
||||
|
||||
// Stop once should be enough
|
||||
c.StopAutoRefresh()
|
||||
|
||||
// Wait for cleanup
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Should be back to initial count
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
|
||||
"Should cleanup all goroutines after final stop (initial: %d, final: %d)",
|
||||
initialGoroutines, finalGoroutines)
|
||||
|
||||
// Should still work correctly
|
||||
assert.GreaterOrEqual(t, c.Count(), initialCount)
|
||||
})
|
||||
|
||||
t.Run("stop without start should not panic", func(t *testing.T) {
|
||||
// Multiple stops should be safe
|
||||
assert.NotPanics(t, func() {
|
||||
c.StopAutoRefresh()
|
||||
c.StopAutoRefresh()
|
||||
c.StopAutoRefresh()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("concurrent start and stop should be safe", func(t *testing.T) {
|
||||
// Record initial goroutine count
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
initialGoroutines := runtime.NumGoroutine()
|
||||
|
||||
config := &cache.RefreshConfig{Interval: 50 * time.Millisecond}
|
||||
|
||||
// Rapidly start and stop multiple times
|
||||
for i := 0; i < 10; i++ {
|
||||
c.StartAutoRefresh(ctx, config)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
c.StopAutoRefresh()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Final cleanup
|
||||
c.StopAutoRefresh()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Should not have leaked goroutines
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
|
||||
"Rapid start/stop cycles should not leak goroutines (initial: %d, final: %d)",
|
||||
initialGoroutines, finalGoroutines)
|
||||
})
|
||||
}
|
||||
|
||||
// setupTestRobots creates 3 test robot records in database
|
||||
func setupTestRobots(t *testing.T) {
|
||||
// Get the actual table name from model
|
||||
m := model.Select("__yao.member")
|
||||
tableName := m.MetaData.Table.Name
|
||||
|
||||
qb := capsule.Query()
|
||||
|
||||
// Robot 1: Sales Bot (active, autonomous)
|
||||
robotConfig1 := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "Sales Manager",
|
||||
"duties": []string{"Manage leads", "Follow up customers"},
|
||||
"rules": []string{"Be professional", "Reply within 24h"},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 3,
|
||||
"queue": 15,
|
||||
"priority": 7,
|
||||
},
|
||||
"clock": map[string]interface{}{
|
||||
"mode": "times",
|
||||
"times": []string{"09:00", "14:00"},
|
||||
"tz": "Asia/Shanghai",
|
||||
},
|
||||
}
|
||||
config1JSON, _ := json.Marshal(robotConfig1)
|
||||
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_sales_001",
|
||||
"team_id": "team_test_cache_001",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test Sales Bot",
|
||||
"system_prompt": "You are a professional sales manager assistant.",
|
||||
"status": "active",
|
||||
"role_id": "member", // required field
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(config1JSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_sales_001: %v", err)
|
||||
}
|
||||
|
||||
// Robot 2: Support Bot (active, autonomous)
|
||||
robotConfig2 := map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"role": "Customer Support",
|
||||
"duties": []string{"Answer questions", "Resolve issues"},
|
||||
},
|
||||
"quota": map[string]interface{}{
|
||||
"max": 2,
|
||||
"queue": 10,
|
||||
"priority": 5,
|
||||
},
|
||||
"clock": map[string]interface{}{
|
||||
"mode": "interval",
|
||||
"every": "1h",
|
||||
},
|
||||
}
|
||||
config2JSON, _ := json.Marshal(robotConfig2)
|
||||
|
||||
err = qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_support_002",
|
||||
"team_id": "team_test_cache_001",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test Support Bot",
|
||||
"system_prompt": "You are a helpful customer support assistant.",
|
||||
"status": "active",
|
||||
"role_id": "member", // required field
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(config2JSON),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_support_002: %v", err)
|
||||
}
|
||||
|
||||
// Robot 3: Inactive robot (should not be loaded by Load())
|
||||
err = qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": "robot_test_inactive_003",
|
||||
"team_id": "team_test_cache_001",
|
||||
"member_type": "robot",
|
||||
"display_name": "Test Inactive Bot",
|
||||
"status": "inactive",
|
||||
"role_id": "member", // required field
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "paused",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert robot_test_inactive_003: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupTestRobots removes test robot records
|
||||
func cleanupTestRobots(t *testing.T) {
|
||||
qb := capsule.Query()
|
||||
|
||||
// Use the member model to perform soft delete
|
||||
m := model.Select("__yao.member")
|
||||
|
||||
// Delete test robots
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: "robot_test_sales_001"},
|
||||
},
|
||||
})
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: "robot_test_support_002"},
|
||||
},
|
||||
})
|
||||
m.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: "robot_test_inactive_003"},
|
||||
},
|
||||
})
|
||||
|
||||
// Hard delete from database (cleanup for next test run)
|
||||
m2 := model.Select("__yao.member")
|
||||
tableName2 := m2.MetaData.Table.Name
|
||||
qb.Table(tableName2).Where("member_id", "robot_test_sales_001").Delete()
|
||||
qb.Table(tableName2).Where("member_id", "robot_test_support_002").Delete()
|
||||
qb.Table(tableName2).Where("member_id", "robot_test_inactive_003").Delete()
|
||||
}
|
||||
115
agent/robot/cache/load.go
vendored
Normal file
115
agent/robot/cache/load.go
vendored
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// memberModel is the model name for member table
|
||||
// Can be changed via SetMemberModel() during system initialization
|
||||
var memberModel = "__yao.member"
|
||||
|
||||
// memberFields are the fields to select when loading robots
|
||||
var memberFields = []interface{}{
|
||||
"id",
|
||||
"member_id",
|
||||
"team_id",
|
||||
"display_name",
|
||||
"system_prompt",
|
||||
"robot_status",
|
||||
"autonomous_mode",
|
||||
"robot_config",
|
||||
}
|
||||
|
||||
// SetMemberModel sets the member model name
|
||||
// Call this during system initialization to override the default
|
||||
func SetMemberModel(model string) {
|
||||
if model != "" {
|
||||
memberModel = model
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads all active robots from database with pagination
|
||||
// Query: member_type='robot' AND autonomous_mode=true AND status='active'
|
||||
func (c *Cache) Load(ctx *types.Context) error {
|
||||
m := model.Select(memberModel)
|
||||
|
||||
// Clear existing cache first
|
||||
c.mu.Lock()
|
||||
c.robots = make(map[string]*types.Robot)
|
||||
c.byTeam = make(map[string][]string)
|
||||
c.mu.Unlock()
|
||||
|
||||
// Paginate to handle large number of robots
|
||||
page := 1
|
||||
pageSize := 100 // load 100 robots per page
|
||||
totalLoaded := 0
|
||||
|
||||
for {
|
||||
// Query with pagination
|
||||
result, err := m.Paginate(model.QueryParam{
|
||||
Select: memberFields,
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_type", Value: "robot"},
|
||||
{Column: "autonomous_mode", Value: true},
|
||||
{Column: "status", Value: "active"},
|
||||
},
|
||||
}, page, pageSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load robots (page %d): %w", page, err)
|
||||
}
|
||||
|
||||
// Extract records from pagination result
|
||||
data, ok := result.Get("data").([]maps.MapStr)
|
||||
if !ok || len(data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse and add each robot
|
||||
for _, record := range data {
|
||||
robot, err := types.NewRobotFromMap(map[string]interface{}(record))
|
||||
if err != nil {
|
||||
// Log error but continue loading other robots
|
||||
continue
|
||||
}
|
||||
c.Add(robot)
|
||||
totalLoaded++
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
total, _ := result.Get("total").(int)
|
||||
if totalLoaded >= total {
|
||||
break
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadByID loads a single robot from database by member ID
|
||||
func (c *Cache) LoadByID(ctx *types.Context, memberID string) (*types.Robot, error) {
|
||||
m := model.Select(memberModel)
|
||||
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: memberFields,
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", Value: memberID},
|
||||
{Column: "member_type", Value: "robot"},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load robot %s: %w", memberID, err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil, types.ErrRobotNotFound
|
||||
}
|
||||
|
||||
return types.NewRobotFromMap(map[string]interface{}(records[0]))
|
||||
}
|
||||
142
agent/robot/cache/refresh.go
vendored
Normal file
142
agent/robot/cache/refresh.go
vendored
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RefreshConfig holds refresh configuration
|
||||
type RefreshConfig struct {
|
||||
Interval time.Duration // full refresh interval (default: 1 hour)
|
||||
}
|
||||
|
||||
// DefaultRefreshConfig returns default refresh configuration
|
||||
func DefaultRefreshConfig() *RefreshConfig {
|
||||
return &RefreshConfig{
|
||||
Interval: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// refreshState holds the refresh goroutine state
|
||||
type refreshState struct {
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var refresher = &refreshState{}
|
||||
|
||||
// Refresh refreshes a single robot's config from database
|
||||
func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
|
||||
robot, err := c.LoadByID(ctx, memberID)
|
||||
if err != nil {
|
||||
// If robot not found or no longer autonomous, remove from cache
|
||||
if err == types.ErrRobotNotFound {
|
||||
c.Remove(memberID)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if robot is still active and autonomous
|
||||
if !robot.AutonomousMode {
|
||||
c.Remove(memberID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update cache
|
||||
c.Add(robot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartAutoRefresh starts periodic full refresh
|
||||
func (c *Cache) StartAutoRefresh(ctx *types.Context, config *RefreshConfig) {
|
||||
if config == nil {
|
||||
config = DefaultRefreshConfig()
|
||||
}
|
||||
|
||||
refresher.mu.Lock()
|
||||
defer refresher.mu.Unlock()
|
||||
|
||||
// Stop existing refresher if any
|
||||
if refresher.done != nil {
|
||||
close(refresher.done)
|
||||
}
|
||||
|
||||
refresher.ticker = time.NewTicker(config.Interval)
|
||||
refresher.done = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-refresher.done:
|
||||
refresher.ticker.Stop()
|
||||
return
|
||||
case <-refresher.ticker.C:
|
||||
// Perform full refresh
|
||||
_ = c.Load(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopAutoRefresh stops the periodic refresh
|
||||
func (c *Cache) StopAutoRefresh() {
|
||||
refresher.mu.Lock()
|
||||
defer refresher.mu.Unlock()
|
||||
|
||||
if refresher.done != nil {
|
||||
close(refresher.done)
|
||||
refresher.done = nil
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshAll reloads all robots from database
|
||||
func (c *Cache) RefreshAll(ctx *types.Context) error {
|
||||
return c.Load(ctx)
|
||||
}
|
||||
|
||||
// Count returns the number of cached robots
|
||||
func (c *Cache) Count() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.robots)
|
||||
}
|
||||
|
||||
// ListAll returns all cached robots (across all teams)
|
||||
func (c *Cache) ListAll() []*types.Robot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
robots := make([]*types.Robot, 0, len(c.robots))
|
||||
for _, robot := range c.robots {
|
||||
robots = append(robots, robot)
|
||||
}
|
||||
return robots
|
||||
}
|
||||
|
||||
// GetByStatus returns robots with the specified status
|
||||
func (c *Cache) GetByStatus(status types.RobotStatus) []*types.Robot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
var robots []*types.Robot
|
||||
for _, robot := range c.robots {
|
||||
if robot.Status == status {
|
||||
robots = append(robots, robot)
|
||||
}
|
||||
}
|
||||
return robots
|
||||
}
|
||||
|
||||
// GetIdle returns all idle robots ready to execute
|
||||
func (c *Cache) GetIdle() []*types.Robot {
|
||||
return c.GetByStatus(types.RobotIdle)
|
||||
}
|
||||
|
||||
// GetWorking returns all currently working robots
|
||||
func (c *Cache) GetWorking() []*types.Robot {
|
||||
return c.GetByStatus(types.RobotWorking)
|
||||
}
|
||||
34
agent/robot/dedup/dedup.go
Normal file
34
agent/robot/dedup/dedup.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package dedup
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Dedup implements types.Dedup interface
|
||||
// This is a stub implementation for Phase 2
|
||||
type Dedup struct {
|
||||
marks map[string]time.Time // key -> expiry time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new dedup instance
|
||||
func New() *Dedup {
|
||||
return &Dedup{
|
||||
marks: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Check checks if execution should be deduplicated
|
||||
// Stub: always returns proceed (will be implemented in Phase 3)
|
||||
func (d *Dedup) Check(ctx *types.Context, memberID string, trigger types.TriggerType) (types.DedupResult, error) {
|
||||
return types.DedupProceed, nil
|
||||
}
|
||||
|
||||
// Mark marks an execution to prevent duplicates within window
|
||||
// Stub: does nothing (will be implemented in Phase 3)
|
||||
func (d *Dedup) Mark(memberID string, trigger types.TriggerType, window time.Duration) {
|
||||
// Stub: no-op
|
||||
}
|
||||
110
agent/robot/executor/executor.go
Normal file
110
agent/robot/executor/executor.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/robot/utils"
|
||||
)
|
||||
|
||||
// Executor implements types.Executor interface
|
||||
// This is a stub implementation for Phase 2
|
||||
type Executor struct {
|
||||
delay time.Duration // simulated execution delay
|
||||
execCount atomic.Int32 // total execution count
|
||||
currentCount atomic.Int32 // currently running count
|
||||
onStart func() // callback on execution start (for testing)
|
||||
onEnd func() // callback on execution end (for testing)
|
||||
}
|
||||
|
||||
// New creates a new executor instance
|
||||
func New() *Executor {
|
||||
return &Executor{}
|
||||
}
|
||||
|
||||
// NewWithDelay creates a new executor with simulated delay (for testing)
|
||||
func NewWithDelay(delay time.Duration) *Executor {
|
||||
return &Executor{
|
||||
delay: delay,
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithCallback creates a new executor with callbacks (for testing concurrency)
|
||||
func NewWithCallback(delay time.Duration, onStart, onEnd func()) *Executor {
|
||||
return &Executor{
|
||||
delay: delay,
|
||||
onStart: onStart,
|
||||
onEnd: onEnd,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes a robot through all phases
|
||||
// Stub: returns empty execution (will be implemented in Phase 3+)
|
||||
func (e *Executor) Execute(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}) (*types.Execution, error) {
|
||||
// Create execution record first
|
||||
execID := utils.NewID()
|
||||
exec := &types.Execution{
|
||||
ID: execID,
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseInspiration,
|
||||
}
|
||||
|
||||
// Atomically check quota and acquire slot
|
||||
// This prevents race condition where multiple workers pass CanRun() check
|
||||
// but then all add executions, exceeding the quota
|
||||
if !robot.TryAcquireSlot(exec) {
|
||||
return nil, types.ErrQuotaExceeded
|
||||
}
|
||||
defer robot.RemoveExecution(execID)
|
||||
|
||||
// Track execution count (after successful slot acquisition)
|
||||
e.execCount.Add(1)
|
||||
e.currentCount.Add(1)
|
||||
defer e.currentCount.Add(-1)
|
||||
|
||||
// Call start callback if set
|
||||
if e.onStart != nil {
|
||||
e.onStart()
|
||||
}
|
||||
// Call end callback on return
|
||||
if e.onEnd != nil {
|
||||
defer e.onEnd()
|
||||
}
|
||||
|
||||
// Simulate execution delay
|
||||
if e.delay > 0 {
|
||||
time.Sleep(e.delay)
|
||||
}
|
||||
|
||||
// Check for simulated failure
|
||||
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
|
||||
exec.Status = types.ExecFailed
|
||||
return exec, nil // return error is optional, we track status
|
||||
}
|
||||
|
||||
// Update execution status
|
||||
exec.Status = types.ExecCompleted
|
||||
exec.Phase = types.PhaseLearning
|
||||
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// ExecCount returns total execution count
|
||||
func (e *Executor) ExecCount() int {
|
||||
return int(e.execCount.Load())
|
||||
}
|
||||
|
||||
// CurrentCount returns currently running execution count
|
||||
func (e *Executor) CurrentCount() int {
|
||||
return int(e.currentCount.Load())
|
||||
}
|
||||
|
||||
// Reset resets the executor counters (for testing)
|
||||
func (e *Executor) Reset() {
|
||||
e.execCount.Store(0)
|
||||
e.currentCount.Store(0)
|
||||
}
|
||||
33
agent/robot/job/job.go
Normal file
33
agent/robot/job/job.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package job
|
||||
|
||||
import "github.com/yaoapp/yao/agent/robot/types"
|
||||
|
||||
// Create creates a new job for robot execution
|
||||
// Stub: returns empty job ID (will be implemented in Phase 3)
|
||||
func Create(ctx *types.Context, exec *types.Execution) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Update updates job status
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func Update(ctx *types.Context, jobID string, status types.ExecStatus, phase types.Phase) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log writes a log entry for the execution
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func Log(ctx *types.Context, jobID string, level string, message string, data map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Complete marks job as completed
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func Complete(ctx *types.Context, jobID string, exec *types.Execution) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fail marks job as failed
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func Fail(ctx *types.Context, jobID string, err error) error {
|
||||
return nil
|
||||
}
|
||||
34
agent/robot/manager/manager.go
Normal file
34
agent/robot/manager/manager.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package manager
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Manager implements types.Manager interface
|
||||
// This is a stub implementation for Phase 2
|
||||
type Manager struct{}
|
||||
|
||||
// New creates a new manager instance
|
||||
func New() *Manager {
|
||||
return &Manager{}
|
||||
}
|
||||
|
||||
// Start starts the manager and clock ticker
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (m *Manager) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the manager gracefully
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (m *Manager) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tick processes a clock tick
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (m *Manager) Tick(ctx *types.Context, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
40
agent/robot/plan/plan.go
Normal file
40
agent/robot/plan/plan.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package plan
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Plan manages planned tasks/goals for later execution
|
||||
// This is a stub implementation for Phase 2
|
||||
type Plan struct{}
|
||||
|
||||
// New creates a new plan instance
|
||||
func New() *Plan {
|
||||
return &Plan{}
|
||||
}
|
||||
|
||||
// Add adds a task or goal to plan queue
|
||||
// Stub: returns nil (will be implemented in Phase 11)
|
||||
func (p *Plan) Add(ctx *types.Context, memberID string, item interface{}, executeAt time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes an item from plan queue
|
||||
// Stub: returns nil (will be implemented in Phase 11)
|
||||
func (p *Plan) Remove(ctx *types.Context, memberID string, itemID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// List lists all planned items for a robot
|
||||
// Stub: returns empty slice (will be implemented in Phase 11)
|
||||
func (p *Plan) List(ctx *types.Context, memberID string) ([]interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
// GetDue returns items that are due for execution
|
||||
// Stub: returns empty slice (will be implemented in Phase 11)
|
||||
func (p *Plan) GetDue(ctx *types.Context, now time.Time) ([]interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
312
agent/robot/pool/goroutine_test.go
Normal file
312
agent/robot/pool/goroutine_test.go
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
package pool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/pool"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ==================== Goroutine Leak Detection Tests ====================
|
||||
|
||||
// getGoroutineCount returns current number of goroutines
|
||||
func getGoroutineCount() int {
|
||||
return runtime.NumGoroutine()
|
||||
}
|
||||
|
||||
// waitForGoroutineCount waits for goroutine count to stabilize
|
||||
func waitForGoroutineCount(target int, timeout time.Duration) int {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var count int
|
||||
for time.Now().Before(deadline) {
|
||||
count = getGoroutineCount()
|
||||
if count <= target {
|
||||
return count
|
||||
}
|
||||
runtime.Gosched()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// TestPoolNoGoroutineLeak tests that pool doesn't leak goroutines after stop
|
||||
func TestPoolNoGoroutineLeak(t *testing.T) {
|
||||
// Get baseline goroutine count
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
// Create and start pool
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5,
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
// Verify workers are running
|
||||
afterStart := getGoroutineCount()
|
||||
assert.Greater(t, afterStart, baseline, "Should have more goroutines after start")
|
||||
|
||||
// Submit some jobs
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
for i := 0; i < 10; i++ {
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for jobs to complete
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Stop pool
|
||||
p.Stop()
|
||||
|
||||
// Wait for goroutines to clean up
|
||||
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
|
||||
|
||||
// Allow small variance (test framework goroutines)
|
||||
assert.LessOrEqual(t, finalCount, baseline+2,
|
||||
"Goroutine count should return to near baseline after stop (baseline=%d, final=%d)", baseline, finalCount)
|
||||
}
|
||||
|
||||
// TestPoolMultipleStartStop tests no leak with multiple start/stop cycles
|
||||
func TestPoolMultipleStartStop(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.NewWithDelay(5 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3,
|
||||
QueueSize: 50,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
// Submit a few jobs
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
for j := 0; j < 5; j++ {
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
p.Stop()
|
||||
}
|
||||
|
||||
// Wait for cleanup
|
||||
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
|
||||
|
||||
assert.LessOrEqual(t, finalCount, baseline+2,
|
||||
"Goroutine count should return to near baseline after multiple cycles (baseline=%d, final=%d)", baseline, finalCount)
|
||||
}
|
||||
|
||||
// TestPoolStopWithoutJobs tests no leak when stopping pool with no jobs submitted
|
||||
func TestPoolStopWithoutJobs(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.New()
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 10,
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
// Immediately stop without submitting any jobs
|
||||
p.Stop()
|
||||
|
||||
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
|
||||
|
||||
assert.LessOrEqual(t, finalCount, baseline+2,
|
||||
"Goroutine count should return to near baseline (baseline=%d, final=%d)", baseline, finalCount)
|
||||
}
|
||||
|
||||
// TestPoolStopWithPendingJobs tests no leak when stopping with jobs in queue
|
||||
func TestPoolStopWithPendingJobs(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
// Use slow executor so jobs stay in queue
|
||||
exec := executor.NewWithDelay(500 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // only 1 worker
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
// Submit many jobs (most will be queued)
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 50, 5)
|
||||
for i := 0; i < 20; i++ {
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Stop immediately (some jobs still in queue)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
p.Stop()
|
||||
|
||||
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
|
||||
|
||||
assert.LessOrEqual(t, finalCount, baseline+2,
|
||||
"Goroutine count should return to near baseline even with pending jobs (baseline=%d, final=%d)", baseline, finalCount)
|
||||
}
|
||||
|
||||
// TestPoolConcurrentStartStop tests no leak with concurrent start/stop
|
||||
func TestPoolConcurrentStartStop(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5,
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
|
||||
// Start pool
|
||||
p.Start()
|
||||
|
||||
// Concurrent operations
|
||||
done := make(chan bool, 3)
|
||||
|
||||
// Goroutine 1: Submit jobs
|
||||
go func() {
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
for i := 0; i < 20; i++ {
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Goroutine 2: Check status
|
||||
go func() {
|
||||
for i := 0; i < 20; i++ {
|
||||
_ = p.Running()
|
||||
_ = p.Queued()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for operations
|
||||
<-done
|
||||
<-done
|
||||
|
||||
// Stop pool
|
||||
p.Stop()
|
||||
|
||||
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
|
||||
|
||||
assert.LessOrEqual(t, finalCount, baseline+2,
|
||||
"Goroutine count should return to near baseline after concurrent ops (baseline=%d, final=%d)", baseline, finalCount)
|
||||
}
|
||||
|
||||
// TestWorkerGoroutinesCleanup tests that worker goroutines are properly cleaned up
|
||||
func TestWorkerGoroutinesCleanup(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
|
||||
// Create pool with many workers
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 20,
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
// Should have baseline + 20 workers
|
||||
afterStart := getGoroutineCount()
|
||||
assert.GreaterOrEqual(t, afterStart, baseline+20, "Should have at least 20 worker goroutines")
|
||||
|
||||
// Stop pool
|
||||
p.Stop()
|
||||
|
||||
// All worker goroutines should be cleaned up
|
||||
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
|
||||
|
||||
assert.LessOrEqual(t, finalCount, baseline+2,
|
||||
"All worker goroutines should be cleaned up (baseline=%d, final=%d)", baseline, finalCount)
|
||||
}
|
||||
|
||||
// TestPoolLongRunningJobsNoLeak tests no leak with long-running jobs
|
||||
func TestPoolLongRunningJobsNoLeak(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.NewWithDelay(200 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3,
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
// Submit jobs
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for some jobs to complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Stop pool
|
||||
p.Stop()
|
||||
|
||||
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
|
||||
|
||||
assert.LessOrEqual(t, finalCount, baseline+2,
|
||||
"No goroutine leak after long-running jobs (baseline=%d, final=%d)", baseline, finalCount)
|
||||
}
|
||||
|
||||
// TestQueueNoGoroutineLeak tests that queue operations don't leak goroutines
|
||||
func TestQueueNoGoroutineLeak(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
// Create queue and perform many operations
|
||||
pq := pool.NewPriorityQueue(1000)
|
||||
|
||||
// Enqueue many items
|
||||
for i := 0; i < 500; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i%26)), "team_1", 5, 100, 5)
|
||||
pq.Enqueue(&pool.QueueItem{
|
||||
Robot: robot,
|
||||
Trigger: types.TriggerClock,
|
||||
})
|
||||
}
|
||||
|
||||
// Dequeue all items
|
||||
for pq.Size() > 0 {
|
||||
pq.Dequeue()
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
finalCount := getGoroutineCount()
|
||||
|
||||
assert.LessOrEqual(t, finalCount, baseline+2,
|
||||
"Queue operations should not leak goroutines (baseline=%d, final=%d)", baseline, finalCount)
|
||||
}
|
||||
195
agent/robot/pool/pool.go
Normal file
195
agent/robot/pool/pool.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Default configuration values
|
||||
const (
|
||||
DefaultWorkerSize = 10 // default number of workers
|
||||
DefaultQueueSize = 100 // default global queue size
|
||||
)
|
||||
|
||||
// Config holds pool configuration
|
||||
type Config struct {
|
||||
WorkerSize int // number of workers (default: 10)
|
||||
QueueSize int // global queue size (default: 100)
|
||||
}
|
||||
|
||||
// DefaultConfig returns default pool configuration
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
WorkerSize: DefaultWorkerSize,
|
||||
QueueSize: DefaultQueueSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Pool implements types.Pool interface
|
||||
// Manages a pool of workers that execute robot jobs from a priority queue
|
||||
type Pool struct {
|
||||
size int // number of workers
|
||||
queue *PriorityQueue // priority queue for pending jobs
|
||||
executor types.Executor // executor for running jobs
|
||||
workers []*Worker // worker goroutines
|
||||
running atomic.Int32 // number of currently running jobs
|
||||
wg sync.WaitGroup // wait group for graceful shutdown
|
||||
started bool // whether pool has been started
|
||||
mu sync.RWMutex // protects started flag
|
||||
}
|
||||
|
||||
// New creates a new pool instance with default configuration
|
||||
func New() *Pool {
|
||||
return NewWithConfig(nil)
|
||||
}
|
||||
|
||||
// NewWithConfig creates a new pool instance with custom configuration
|
||||
func NewWithConfig(config *Config) *Pool {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
// Apply defaults for zero values
|
||||
workerSize := config.WorkerSize
|
||||
if workerSize <= 0 {
|
||||
workerSize = DefaultWorkerSize
|
||||
}
|
||||
|
||||
queueSize := config.QueueSize
|
||||
if queueSize <= 0 {
|
||||
queueSize = DefaultQueueSize
|
||||
}
|
||||
|
||||
return &Pool{
|
||||
size: workerSize,
|
||||
queue: NewPriorityQueue(queueSize),
|
||||
}
|
||||
}
|
||||
|
||||
// SetExecutor sets the executor for the pool
|
||||
// Must be called before Start()
|
||||
func (p *Pool) SetExecutor(executor types.Executor) {
|
||||
p.executor = executor
|
||||
}
|
||||
|
||||
// Start starts the worker pool
|
||||
func (p *Pool) Start() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.started {
|
||||
return fmt.Errorf("pool already started")
|
||||
}
|
||||
|
||||
if p.executor == nil {
|
||||
return fmt.Errorf("executor not set, call SetExecutor() first")
|
||||
}
|
||||
|
||||
// Create and start workers
|
||||
p.workers = make([]*Worker, p.size)
|
||||
for i := 0; i < p.size; i++ {
|
||||
worker := newWorker(i+1, p, p.executor, &p.wg)
|
||||
p.workers[i] = worker
|
||||
worker.start()
|
||||
}
|
||||
|
||||
p.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the worker pool gracefully
|
||||
// Waits for all running jobs to complete
|
||||
func (p *Pool) Stop() error {
|
||||
p.mu.Lock()
|
||||
if !p.started {
|
||||
p.mu.Unlock()
|
||||
return nil // already stopped or never started
|
||||
}
|
||||
p.started = false
|
||||
p.mu.Unlock()
|
||||
|
||||
// Stop all workers
|
||||
for _, worker := range p.workers {
|
||||
worker.stop()
|
||||
}
|
||||
|
||||
// Wait for all workers to finish
|
||||
p.wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit submits a robot execution to the pool
|
||||
// Returns execution ID if successfully queued, error otherwise
|
||||
func (p *Pool) Submit(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}) (string, error) {
|
||||
p.mu.RLock()
|
||||
if !p.started {
|
||||
p.mu.RUnlock()
|
||||
return "", fmt.Errorf("pool not started")
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
if robot == nil {
|
||||
return "", fmt.Errorf("robot cannot be nil")
|
||||
}
|
||||
|
||||
// Create queue item
|
||||
item := &QueueItem{
|
||||
Robot: robot,
|
||||
Ctx: ctx,
|
||||
Trigger: trigger,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
// Try to add to queue
|
||||
if !p.queue.Enqueue(item) {
|
||||
return "", fmt.Errorf("queue full (max %d items)", p.queue.maxSize)
|
||||
}
|
||||
|
||||
// Generate execution ID for tracking
|
||||
// Note: Actual execution ID will be generated by Executor
|
||||
// This is just a placeholder for the Submit return value
|
||||
execID := fmt.Sprintf("queued_%s_%d", robot.MemberID, item.EnqueueTime.Unix())
|
||||
|
||||
return execID, nil
|
||||
}
|
||||
|
||||
// Running returns number of currently running jobs
|
||||
func (p *Pool) Running() int {
|
||||
return int(p.running.Load())
|
||||
}
|
||||
|
||||
// Queued returns number of queued jobs
|
||||
func (p *Pool) Queued() int {
|
||||
return p.queue.Size()
|
||||
}
|
||||
|
||||
// incrementRunning increments the running counter
|
||||
func (p *Pool) incrementRunning() {
|
||||
p.running.Add(1)
|
||||
}
|
||||
|
||||
// decrementRunning decrements the running counter
|
||||
func (p *Pool) decrementRunning() {
|
||||
p.running.Add(-1)
|
||||
}
|
||||
|
||||
// Size returns the configured pool size
|
||||
func (p *Pool) Size() int {
|
||||
return p.size
|
||||
}
|
||||
|
||||
// QueueSize returns the configured queue size
|
||||
func (p *Pool) QueueSize() int {
|
||||
return p.queue.maxSize
|
||||
}
|
||||
|
||||
// IsStarted returns true if the pool has been started
|
||||
func (p *Pool) IsStarted() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.started
|
||||
}
|
||||
418
agent/robot/pool/pool_test.go
Normal file
418
agent/robot/pool/pool_test.go
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
package pool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/pool"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// createTestRobot creates a robot for testing with specified quota
|
||||
func createTestRobot(memberID, teamID string, maxConcurrent, queueSize, priority int) *types.Robot {
|
||||
return &types.Robot{
|
||||
MemberID: memberID,
|
||||
TeamID: teamID,
|
||||
DisplayName: "Test Robot " + memberID,
|
||||
Status: types.RobotIdle,
|
||||
AutonomousMode: true,
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{Role: "Test"},
|
||||
Quota: &types.Quota{
|
||||
Max: maxConcurrent,
|
||||
Queue: queueSize,
|
||||
Priority: priority,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createTestContext creates a context for testing
|
||||
func createTestContext() *types.Context {
|
||||
return types.NewContext(context.Background(), nil)
|
||||
}
|
||||
|
||||
// TestPoolStartStop tests pool start and stop lifecycle
|
||||
func TestPoolStartStop(t *testing.T) {
|
||||
p := pool.New()
|
||||
exec := executor.New()
|
||||
p.SetExecutor(exec)
|
||||
|
||||
t.Run("start pool", func(t *testing.T) {
|
||||
err := p.Start()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, p.IsStarted())
|
||||
})
|
||||
|
||||
t.Run("start already started pool", func(t *testing.T) {
|
||||
err := p.Start()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already started")
|
||||
})
|
||||
|
||||
t.Run("stop pool", func(t *testing.T) {
|
||||
err := p.Stop()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, p.IsStarted())
|
||||
})
|
||||
|
||||
t.Run("stop already stopped pool", func(t *testing.T) {
|
||||
err := p.Stop()
|
||||
assert.NoError(t, err) // should not error
|
||||
})
|
||||
}
|
||||
|
||||
// TestPoolSubmitWithoutStart tests submitting to unstarted pool
|
||||
func TestPoolSubmitWithoutStart(t *testing.T) {
|
||||
p := pool.New()
|
||||
exec := executor.New()
|
||||
p.SetExecutor(exec)
|
||||
|
||||
robot := createTestRobot("robot_1", "team_1", 2, 10, 5)
|
||||
ctx := createTestContext()
|
||||
|
||||
_, err := p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
}
|
||||
|
||||
// TestPoolSubmitNilRobot tests submitting nil robot
|
||||
func TestPoolSubmitNilRobot(t *testing.T) {
|
||||
p := pool.New()
|
||||
exec := executor.New()
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
_, err := p.Submit(ctx, nil, types.TriggerClock, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be nil")
|
||||
}
|
||||
|
||||
// TestPoolBasicExecution tests basic job execution
|
||||
func TestPoolBasicExecution(t *testing.T) {
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5,
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
robot := createTestRobot("robot_1", "team_1", 2, 10, 5)
|
||||
ctx := createTestContext()
|
||||
|
||||
// Submit a job
|
||||
execID, err := p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, execID)
|
||||
|
||||
// Wait for execution
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Verify execution completed
|
||||
assert.Equal(t, 1, exec.ExecCount())
|
||||
assert.Equal(t, 0, exec.CurrentCount())
|
||||
}
|
||||
|
||||
// TestPoolConcurrencyLimit tests global worker limit
|
||||
func TestPoolConcurrencyLimit(t *testing.T) {
|
||||
exec := executor.NewWithDelay(200 * time.Millisecond) // longer delay
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3, // only 3 workers
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Create robots with high quota (won't be the bottleneck)
|
||||
robots := make([]*types.Robot, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
robots[i] = createTestRobot(
|
||||
"robot_"+string(rune('A'+i)),
|
||||
"team_1",
|
||||
5, // max concurrent per robot
|
||||
20, // queue size per robot
|
||||
5, // priority
|
||||
)
|
||||
}
|
||||
|
||||
// Submit 10 jobs
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err := p.Submit(ctx, robots[i], types.TriggerClock, nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for workers to pick up jobs (worker polls every 100ms)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Should have at most 3 running (worker limit)
|
||||
running := p.Running()
|
||||
assert.LessOrEqual(t, running, 3, "Should not exceed worker limit")
|
||||
|
||||
// Wait for all to complete
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
assert.Equal(t, 10, exec.ExecCount())
|
||||
}
|
||||
|
||||
// TestRobotConcurrencyLimit tests per-robot concurrent execution limit
|
||||
func TestRobotConcurrencyLimit(t *testing.T) {
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 10, // plenty of workers
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Create robot with Max=2 (can only run 2 at a time)
|
||||
robot := createTestRobot("robot_limited", "team_1", 2, 20, 5)
|
||||
|
||||
// Submit 5 jobs for the same robot
|
||||
for i := 0; i < 5; i++ {
|
||||
_, err := p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait a bit for execution to start
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Robot should have at most 2 running (Quota.Max=2)
|
||||
runningCount := robot.RunningCount()
|
||||
assert.LessOrEqual(t, runningCount, 2, "Robot should not exceed Quota.Max")
|
||||
|
||||
// Wait for all to complete (with re-enqueue, need more time)
|
||||
// 5 jobs with Max=2: ~3 batches * 100ms exec + poll overhead
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
|
||||
// All 5 jobs should eventually execute
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 5, "All jobs should eventually execute")
|
||||
}
|
||||
|
||||
// TestRobotQueueLimit tests per-robot queue limit
|
||||
func TestRobotQueueLimit(t *testing.T) {
|
||||
exec := executor.NewWithDelay(200 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 2,
|
||||
QueueSize: 100, // global queue is large
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Create robot with small queue limit
|
||||
robot := createTestRobot("robot_small_queue", "team_1", 1, 3, 5) // Queue=3
|
||||
|
||||
// Submit jobs until queue limit is reached
|
||||
successCount := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err := p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Should only accept up to Queue limit (some may have started executing)
|
||||
// Max accepted = Queue(3) + Max(1) = 4 (1 running + 3 in queue)
|
||||
assert.LessOrEqual(t, successCount, 4, "Should respect robot queue limit")
|
||||
assert.GreaterOrEqual(t, successCount, 1, "Should accept at least 1 job")
|
||||
}
|
||||
|
||||
// TestGlobalQueueLimit tests global queue limit
|
||||
func TestGlobalQueueLimit(t *testing.T) {
|
||||
exec := executor.NewWithDelay(500 * time.Millisecond) // slow execution
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // only 1 worker
|
||||
QueueSize: 5, // small global queue
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Create multiple robots with large queue limits
|
||||
successCount := 0
|
||||
for i := 0; i < 20; i++ {
|
||||
robot := createTestRobot(
|
||||
"robot_"+string(rune('A'+i%26)),
|
||||
"team_1",
|
||||
5, // large max
|
||||
20, // large per-robot queue
|
||||
5,
|
||||
)
|
||||
_, err := p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Should only accept up to global queue limit + running
|
||||
// Max = QueueSize(5) + WorkerSize(1) = 6
|
||||
assert.LessOrEqual(t, successCount, 6, "Should respect global queue limit")
|
||||
}
|
||||
|
||||
// TestPriorityOrder tests that higher priority jobs execute first
|
||||
func TestPriorityOrder(t *testing.T) {
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // single worker to ensure order
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Create robots with different priorities
|
||||
robotLow := createTestRobot("robot_low", "team_1", 5, 20, 1) // priority 1
|
||||
robotMed := createTestRobot("robot_med", "team_1", 5, 20, 5) // priority 5
|
||||
robotHigh := createTestRobot("robot_high", "team_1", 5, 20, 10) // priority 10
|
||||
|
||||
// Submit in low-to-high order
|
||||
p.Submit(ctx, robotLow, types.TriggerClock, nil)
|
||||
p.Submit(ctx, robotMed, types.TriggerClock, nil)
|
||||
p.Submit(ctx, robotHigh, types.TriggerClock, nil)
|
||||
|
||||
// Wait for all to complete
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
// Verify all executed
|
||||
assert.Equal(t, 3, exec.ExecCount())
|
||||
}
|
||||
|
||||
// TestTriggerTypePriority tests that human triggers have higher priority than clock
|
||||
func TestTriggerTypePriority(t *testing.T) {
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // single worker
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Same robot, same priority, different trigger types
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 20, 5)
|
||||
|
||||
// Submit clock first, then human
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
p.Submit(ctx, robot, types.TriggerHuman, nil) // should execute first
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
assert.Equal(t, 2, exec.ExecCount())
|
||||
}
|
||||
|
||||
// TestMultipleRobotsFairness tests that multiple robots get fair access
|
||||
func TestMultipleRobotsFairness(t *testing.T) {
|
||||
exec := executor.NewWithDelay(30 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5,
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Create 3 robots with same priority
|
||||
robotA := createTestRobot("robot_A", "team_1", 2, 10, 5)
|
||||
robotB := createTestRobot("robot_B", "team_1", 2, 10, 5)
|
||||
robotC := createTestRobot("robot_C", "team_1", 2, 10, 5)
|
||||
|
||||
// Submit jobs for each robot
|
||||
for i := 0; i < 6; i++ {
|
||||
p.Submit(ctx, robotA, types.TriggerClock, nil)
|
||||
p.Submit(ctx, robotB, types.TriggerClock, nil)
|
||||
p.Submit(ctx, robotC, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// All 18 jobs should complete
|
||||
assert.Equal(t, 18, exec.ExecCount())
|
||||
}
|
||||
|
||||
// TestGracefulShutdown tests that pool waits for running jobs on shutdown
|
||||
func TestGracefulShutdown(t *testing.T) {
|
||||
exec := executor.NewWithDelay(200 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 2,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 20, 5)
|
||||
|
||||
// Submit 2 jobs
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
|
||||
// Wait for workers to pick up jobs (poll every 100ms)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Verify jobs are running
|
||||
assert.GreaterOrEqual(t, p.Running(), 1, "Should have at least 1 running job")
|
||||
|
||||
// Stop - workers will finish their current tick cycle
|
||||
p.Stop()
|
||||
|
||||
// After stop, verify jobs completed
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 1, "Should have executed at least 1 job")
|
||||
}
|
||||
|
||||
// TestDefaultConfig tests default configuration values
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
config := pool.DefaultConfig()
|
||||
assert.Equal(t, pool.DefaultWorkerSize, config.WorkerSize)
|
||||
assert.Equal(t, pool.DefaultQueueSize, config.QueueSize)
|
||||
}
|
||||
|
||||
// TestPoolWithNilConfig tests pool creation with nil config
|
||||
func TestPoolWithNilConfig(t *testing.T) {
|
||||
p := pool.NewWithConfig(nil)
|
||||
assert.Equal(t, pool.DefaultWorkerSize, p.Size())
|
||||
assert.Equal(t, pool.DefaultQueueSize, p.QueueSize())
|
||||
}
|
||||
|
||||
// TestPoolWithZeroConfig tests pool creation with zero values
|
||||
func TestPoolWithZeroConfig(t *testing.T) {
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 0,
|
||||
QueueSize: 0,
|
||||
})
|
||||
// Should use defaults for zero values
|
||||
assert.Equal(t, pool.DefaultWorkerSize, p.Size())
|
||||
assert.Equal(t, pool.DefaultQueueSize, p.QueueSize())
|
||||
}
|
||||
|
||||
// TestPoolWithoutExecutor tests starting pool without executor
|
||||
func TestPoolWithoutExecutor(t *testing.T) {
|
||||
p := pool.New()
|
||||
// Don't set executor
|
||||
err := p.Start()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "executor not set")
|
||||
}
|
||||
201
agent/robot/pool/queue.go
Normal file
201
agent/robot/pool/queue.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// QueueItem represents a job waiting in the queue
|
||||
type QueueItem struct {
|
||||
Robot *types.Robot
|
||||
Ctx *types.Context
|
||||
Trigger types.TriggerType
|
||||
Data interface{}
|
||||
EnqueueTime time.Time
|
||||
Priority int // calculated priority for sorting
|
||||
Index int // index in heap (managed by container/heap)
|
||||
}
|
||||
|
||||
// PriorityQueue implements a priority queue for robot executions
|
||||
// Sorted by: robot priority > trigger type priority > wait time
|
||||
type PriorityQueue struct {
|
||||
items []*QueueItem
|
||||
mu sync.RWMutex
|
||||
maxSize int // global queue size limit
|
||||
robotCount map[string]int // per-robot queue count: memberID -> count
|
||||
}
|
||||
|
||||
// NewPriorityQueue creates a new priority queue
|
||||
func NewPriorityQueue(maxSize int) *PriorityQueue {
|
||||
pq := &PriorityQueue{
|
||||
items: make([]*QueueItem, 0),
|
||||
maxSize: maxSize,
|
||||
robotCount: make(map[string]int),
|
||||
}
|
||||
heap.Init(pq)
|
||||
return pq
|
||||
}
|
||||
|
||||
// Enqueue adds an item to the queue
|
||||
// Returns false if:
|
||||
// - Global queue is full (maxSize)
|
||||
// - Robot's queue limit reached (Quota.Queue)
|
||||
func (pq *PriorityQueue) Enqueue(item *QueueItem) bool {
|
||||
pq.mu.Lock()
|
||||
defer pq.mu.Unlock()
|
||||
|
||||
// Check 1: Global queue limit
|
||||
if pq.maxSize > 0 && len(pq.items) >= pq.maxSize {
|
||||
return false // global queue full
|
||||
}
|
||||
|
||||
// Check 2: Per-robot queue limit (prevents single robot from hogging the queue)
|
||||
if item.Robot != nil {
|
||||
memberID := item.Robot.MemberID
|
||||
robotQueueLimit := 10 // default
|
||||
if item.Robot.Config != nil && item.Robot.Config.Quota != nil {
|
||||
robotQueueLimit = item.Robot.Config.Quota.GetQueue()
|
||||
}
|
||||
|
||||
if pq.robotCount[memberID] >= robotQueueLimit {
|
||||
return false // robot's queue limit reached
|
||||
}
|
||||
|
||||
// Increment robot's queue count
|
||||
pq.robotCount[memberID]++
|
||||
}
|
||||
|
||||
item.Priority = calculatePriority(item)
|
||||
item.EnqueueTime = time.Now()
|
||||
heap.Push(pq, item)
|
||||
return true
|
||||
}
|
||||
|
||||
// Dequeue removes and returns the highest priority item
|
||||
// Returns nil if queue is empty
|
||||
func (pq *PriorityQueue) Dequeue() *QueueItem {
|
||||
pq.mu.Lock()
|
||||
defer pq.mu.Unlock()
|
||||
|
||||
if len(pq.items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
item := heap.Pop(pq).(*QueueItem)
|
||||
|
||||
// Decrement robot's queue count
|
||||
if item.Robot != nil {
|
||||
memberID := item.Robot.MemberID
|
||||
if pq.robotCount[memberID] > 0 {
|
||||
pq.robotCount[memberID]--
|
||||
}
|
||||
// Clean up if count reaches zero
|
||||
if pq.robotCount[memberID] == 0 {
|
||||
delete(pq.robotCount, memberID)
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// Size returns the number of items in the queue (thread-safe)
|
||||
func (pq *PriorityQueue) Size() int {
|
||||
pq.mu.RLock()
|
||||
defer pq.mu.RUnlock()
|
||||
return len(pq.items)
|
||||
}
|
||||
|
||||
// IsFull returns true if queue has reached max capacity
|
||||
func (pq *PriorityQueue) IsFull() bool {
|
||||
pq.mu.RLock()
|
||||
defer pq.mu.RUnlock()
|
||||
return pq.maxSize > 0 && len(pq.items) >= pq.maxSize
|
||||
}
|
||||
|
||||
// RobotQueuedCount returns the number of queued items for a specific robot
|
||||
func (pq *PriorityQueue) RobotQueuedCount(memberID string) int {
|
||||
pq.mu.RLock()
|
||||
defer pq.mu.RUnlock()
|
||||
return pq.robotCount[memberID]
|
||||
}
|
||||
|
||||
// ==================== heap.Interface implementation ====================
|
||||
// These methods are called internally by heap.Push/Pop with lock already held
|
||||
|
||||
func (pq *PriorityQueue) Len() int { return len(pq.items) }
|
||||
|
||||
func (pq *PriorityQueue) Less(i, j int) bool {
|
||||
// Higher priority value = higher priority (processed first)
|
||||
// If priority is equal, older items (earlier EnqueueTime) come first
|
||||
if pq.items[i].Priority == pq.items[j].Priority {
|
||||
return pq.items[i].EnqueueTime.Before(pq.items[j].EnqueueTime)
|
||||
}
|
||||
return pq.items[i].Priority > pq.items[j].Priority
|
||||
}
|
||||
|
||||
func (pq *PriorityQueue) Swap(i, j int) {
|
||||
pq.items[i], pq.items[j] = pq.items[j], pq.items[i]
|
||||
pq.items[i].Index = i
|
||||
pq.items[j].Index = j
|
||||
}
|
||||
|
||||
// Push is required by heap.Interface
|
||||
// Note: This is called by heap.Push(), not directly
|
||||
func (pq *PriorityQueue) Push(x interface{}) {
|
||||
item := x.(*QueueItem)
|
||||
item.Index = len(pq.items)
|
||||
pq.items = append(pq.items, item)
|
||||
}
|
||||
|
||||
// Pop is required by heap.Interface
|
||||
// Note: This is called by heap.Pop(), not directly
|
||||
func (pq *PriorityQueue) Pop() interface{} {
|
||||
old := pq.items
|
||||
n := len(old)
|
||||
item := old[n-1]
|
||||
old[n-1] = nil // avoid memory leak
|
||||
item.Index = -1 // mark as removed
|
||||
pq.items = old[0 : n-1]
|
||||
return item
|
||||
}
|
||||
|
||||
// ==================== Priority Calculation ====================
|
||||
|
||||
// calculatePriority calculates the priority score for a queue item
|
||||
// Priority = robot_priority * 1000 + trigger_priority * 100
|
||||
// Higher score = higher priority
|
||||
func calculatePriority(item *QueueItem) int {
|
||||
priority := 0
|
||||
|
||||
// 1. Robot priority (from config, 1-10, default 5)
|
||||
if item.Robot != nil && item.Robot.Config != nil && item.Robot.Config.Quota != nil {
|
||||
robotPriority := item.Robot.Config.Quota.GetPriority()
|
||||
priority += robotPriority * 1000
|
||||
} else {
|
||||
priority += 5000 // default robot priority
|
||||
}
|
||||
|
||||
// 2. Trigger type priority
|
||||
// Human intervention > Event > Clock
|
||||
triggerPriority := getTriggerPriority(item.Trigger)
|
||||
priority += triggerPriority * 100
|
||||
|
||||
return priority
|
||||
}
|
||||
|
||||
// getTriggerPriority returns priority value for trigger type
|
||||
func getTriggerPriority(trigger types.TriggerType) int {
|
||||
switch trigger {
|
||||
case types.TriggerHuman:
|
||||
return 10 // highest priority
|
||||
case types.TriggerEvent:
|
||||
return 5 // medium priority
|
||||
case types.TriggerClock:
|
||||
return 1 // lowest priority
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
510
agent/robot/pool/queue_test.go
Normal file
510
agent/robot/pool/queue_test.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
package pool_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/pool"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ==================== Priority Queue Basic Tests ====================
|
||||
|
||||
// TestQueueNewPriorityQueue tests queue creation
|
||||
func TestQueueNewPriorityQueue(t *testing.T) {
|
||||
t.Run("create with positive size", func(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
assert.NotNil(t, pq)
|
||||
assert.Equal(t, 0, pq.Size())
|
||||
assert.False(t, pq.IsFull())
|
||||
})
|
||||
|
||||
t.Run("create with zero size (unlimited)", func(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(0)
|
||||
assert.NotNil(t, pq)
|
||||
assert.False(t, pq.IsFull()) // never full when maxSize=0
|
||||
})
|
||||
}
|
||||
|
||||
// TestQueueEnqueueDequeue tests basic enqueue and dequeue
|
||||
func TestQueueEnqueueDequeue(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
ctx := createTestContext()
|
||||
|
||||
t.Run("enqueue single item", func(t *testing.T) {
|
||||
item := &pool.QueueItem{
|
||||
Robot: robot,
|
||||
Ctx: ctx,
|
||||
Trigger: types.TriggerClock,
|
||||
Data: "test_data",
|
||||
}
|
||||
ok := pq.Enqueue(item)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 1, pq.Size())
|
||||
})
|
||||
|
||||
t.Run("dequeue single item", func(t *testing.T) {
|
||||
item := pq.Dequeue()
|
||||
assert.NotNil(t, item)
|
||||
assert.Equal(t, "robot_1", item.Robot.MemberID)
|
||||
assert.Equal(t, "test_data", item.Data)
|
||||
assert.Equal(t, 0, pq.Size())
|
||||
})
|
||||
|
||||
t.Run("dequeue from empty queue", func(t *testing.T) {
|
||||
item := pq.Dequeue()
|
||||
assert.Nil(t, item)
|
||||
})
|
||||
}
|
||||
|
||||
// TestQueueSize tests Size method
|
||||
func TestQueueSize(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
assert.Equal(t, 0, pq.Size())
|
||||
|
||||
// Add 5 items
|
||||
for i := 0; i < 5; i++ {
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
}
|
||||
assert.Equal(t, 5, pq.Size())
|
||||
|
||||
// Remove 2 items
|
||||
pq.Dequeue()
|
||||
pq.Dequeue()
|
||||
assert.Equal(t, 3, pq.Size())
|
||||
}
|
||||
|
||||
// ==================== Global Queue Limit Tests ====================
|
||||
|
||||
// TestQueueGlobalLimit tests global queue size limit
|
||||
func TestQueueGlobalLimit(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(5) // max 5 items
|
||||
|
||||
// Create different robots to avoid per-robot limit
|
||||
for i := 0; i < 10; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
|
||||
item := &pool.QueueItem{Robot: robot, Trigger: types.TriggerClock}
|
||||
ok := pq.Enqueue(item)
|
||||
|
||||
if i < 5 {
|
||||
assert.True(t, ok, "Should accept item %d", i)
|
||||
} else {
|
||||
assert.False(t, ok, "Should reject item %d (queue full)", i)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 5, pq.Size())
|
||||
assert.True(t, pq.IsFull())
|
||||
}
|
||||
|
||||
// TestQueueUnlimitedSize tests queue with no size limit (maxSize=0)
|
||||
func TestQueueUnlimitedSize(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(0) // unlimited
|
||||
|
||||
// Add many items
|
||||
for i := 0; i < 100; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i%26)), "team_1", 5, 1000, 5)
|
||||
item := &pool.QueueItem{Robot: robot, Trigger: types.TriggerClock}
|
||||
ok := pq.Enqueue(item)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
assert.Equal(t, 100, pq.Size())
|
||||
assert.False(t, pq.IsFull()) // never full
|
||||
}
|
||||
|
||||
// ==================== Per-Robot Queue Limit Tests ====================
|
||||
|
||||
// TestQueuePerRobotLimit tests per-robot queue limit (Quota.Queue)
|
||||
func TestQueuePerRobotLimit(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100) // large global limit
|
||||
|
||||
// Robot with Queue=3
|
||||
robot := createTestRobot("robot_limited", "team_1", 5, 3, 5)
|
||||
|
||||
// Try to add 10 items for same robot
|
||||
successCount := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
item := &pool.QueueItem{Robot: robot, Trigger: types.TriggerClock}
|
||||
if pq.Enqueue(item) {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Should only accept Queue(3) items
|
||||
assert.Equal(t, 3, successCount)
|
||||
assert.Equal(t, 3, pq.Size())
|
||||
assert.Equal(t, 3, pq.RobotQueuedCount("robot_limited"))
|
||||
}
|
||||
|
||||
// TestQueueMultipleRobotsIndependentLimits tests that each robot has independent queue limit
|
||||
func TestQueueMultipleRobotsIndependentLimits(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
// Robot A: Queue=2
|
||||
robotA := createTestRobot("robot_A", "team_1", 5, 2, 5)
|
||||
// Robot B: Queue=3
|
||||
robotB := createTestRobot("robot_B", "team_1", 5, 3, 5)
|
||||
|
||||
// Add items for Robot A
|
||||
for i := 0; i < 5; i++ {
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robotA, Trigger: types.TriggerClock})
|
||||
}
|
||||
assert.Equal(t, 2, pq.RobotQueuedCount("robot_A"))
|
||||
|
||||
// Add items for Robot B
|
||||
for i := 0; i < 5; i++ {
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robotB, Trigger: types.TriggerClock})
|
||||
}
|
||||
assert.Equal(t, 3, pq.RobotQueuedCount("robot_B"))
|
||||
|
||||
// Total in queue
|
||||
assert.Equal(t, 5, pq.Size())
|
||||
}
|
||||
|
||||
// TestQueueRobotCountAfterDequeue tests robot count decrements after dequeue
|
||||
func TestQueueRobotCountAfterDequeue(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Add 3 items
|
||||
for i := 0; i < 3; i++ {
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
}
|
||||
assert.Equal(t, 3, pq.RobotQueuedCount("robot_1"))
|
||||
|
||||
// Dequeue 2
|
||||
pq.Dequeue()
|
||||
assert.Equal(t, 2, pq.RobotQueuedCount("robot_1"))
|
||||
pq.Dequeue()
|
||||
assert.Equal(t, 1, pq.RobotQueuedCount("robot_1"))
|
||||
|
||||
// Dequeue last
|
||||
pq.Dequeue()
|
||||
assert.Equal(t, 0, pq.RobotQueuedCount("robot_1"))
|
||||
}
|
||||
|
||||
// TestQueueNilRobot tests handling of nil robot
|
||||
func TestQueueNilRobot(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
// Item with nil robot should still be enqueued
|
||||
item := &pool.QueueItem{
|
||||
Robot: nil,
|
||||
Trigger: types.TriggerClock,
|
||||
}
|
||||
ok := pq.Enqueue(item)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 1, pq.Size())
|
||||
|
||||
// Dequeue should work
|
||||
dequeued := pq.Dequeue()
|
||||
assert.NotNil(t, dequeued)
|
||||
assert.Nil(t, dequeued.Robot)
|
||||
}
|
||||
|
||||
// TestQueueDefaultRobotQueueLimit tests default queue limit when Quota is nil
|
||||
func TestQueueDefaultRobotQueueLimit(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
// Robot without Config
|
||||
robot := &types.Robot{
|
||||
MemberID: "robot_no_config",
|
||||
TeamID: "team_1",
|
||||
}
|
||||
|
||||
// Should use default queue limit (10)
|
||||
successCount := 0
|
||||
for i := 0; i < 15; i++ {
|
||||
item := &pool.QueueItem{Robot: robot, Trigger: types.TriggerClock}
|
||||
if pq.Enqueue(item) {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 10, successCount) // default queue limit
|
||||
}
|
||||
|
||||
// ==================== Priority Tests ====================
|
||||
|
||||
// TestQueuePriorityByRobotPriority tests sorting by robot priority
|
||||
func TestQueuePriorityByRobotPriority(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
// Add robots with different priorities (low to high)
|
||||
robotLow := createTestRobot("robot_low", "team_1", 5, 10, 1)
|
||||
robotMed := createTestRobot("robot_med", "team_1", 5, 10, 5)
|
||||
robotHigh := createTestRobot("robot_high", "team_1", 5, 10, 10)
|
||||
|
||||
// Add in low-to-high order
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robotLow, Trigger: types.TriggerClock})
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robotMed, Trigger: types.TriggerClock})
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robotHigh, Trigger: types.TriggerClock})
|
||||
|
||||
// Dequeue should return high priority first
|
||||
item1 := pq.Dequeue()
|
||||
assert.Equal(t, "robot_high", item1.Robot.MemberID)
|
||||
|
||||
item2 := pq.Dequeue()
|
||||
assert.Equal(t, "robot_med", item2.Robot.MemberID)
|
||||
|
||||
item3 := pq.Dequeue()
|
||||
assert.Equal(t, "robot_low", item3.Robot.MemberID)
|
||||
}
|
||||
|
||||
// TestQueuePriorityByTriggerType tests sorting by trigger type
|
||||
func TestQueuePriorityByTriggerType(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
// Same robot, different trigger types
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Add in clock -> event -> human order
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerEvent})
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerHuman})
|
||||
|
||||
// Dequeue should return human first (highest trigger priority)
|
||||
item1 := pq.Dequeue()
|
||||
assert.Equal(t, types.TriggerHuman, item1.Trigger)
|
||||
|
||||
item2 := pq.Dequeue()
|
||||
assert.Equal(t, types.TriggerEvent, item2.Trigger)
|
||||
|
||||
item3 := pq.Dequeue()
|
||||
assert.Equal(t, types.TriggerClock, item3.Trigger)
|
||||
}
|
||||
|
||||
// TestQueuePriorityRobotOverTrigger tests that robot priority > trigger priority
|
||||
func TestQueuePriorityRobotOverTrigger(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
// Low priority robot with human trigger
|
||||
robotLow := createTestRobot("robot_low", "team_1", 5, 10, 1)
|
||||
// High priority robot with clock trigger
|
||||
robotHigh := createTestRobot("robot_high", "team_1", 5, 10, 10)
|
||||
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robotLow, Trigger: types.TriggerHuman})
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robotHigh, Trigger: types.TriggerClock})
|
||||
|
||||
// Robot priority (10*1000=10000) > trigger priority (1*1000+10*100=2000)
|
||||
// So high priority robot should come first even with lower trigger type
|
||||
item1 := pq.Dequeue()
|
||||
assert.Equal(t, "robot_high", item1.Robot.MemberID)
|
||||
|
||||
item2 := pq.Dequeue()
|
||||
assert.Equal(t, "robot_low", item2.Robot.MemberID)
|
||||
}
|
||||
|
||||
// TestQueuePriorityByEnqueueTime tests FIFO for same priority
|
||||
func TestQueuePriorityByEnqueueTime(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
// Same robot, same trigger type (same priority)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Add items with slight delay to ensure different EnqueueTime
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock, Data: "first"})
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock, Data: "second"})
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock, Data: "third"})
|
||||
|
||||
// Should dequeue in FIFO order (earlier EnqueueTime first)
|
||||
item1 := pq.Dequeue()
|
||||
assert.Equal(t, "first", item1.Data)
|
||||
|
||||
item2 := pq.Dequeue()
|
||||
assert.Equal(t, "second", item2.Data)
|
||||
|
||||
item3 := pq.Dequeue()
|
||||
assert.Equal(t, "third", item3.Data)
|
||||
}
|
||||
|
||||
// ==================== Concurrency Tests ====================
|
||||
|
||||
// TestQueueConcurrentEnqueue tests concurrent enqueue operations
|
||||
func TestQueueConcurrentEnqueue(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(1000)
|
||||
|
||||
// Concurrently add items from multiple goroutines
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+id)), "team_1", 5, 100, 5)
|
||||
for j := 0; j < 50; j++ {
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Should have 10 robots * 50 items = 500 items
|
||||
assert.Equal(t, 500, pq.Size())
|
||||
}
|
||||
|
||||
// TestQueueConcurrentDequeue tests concurrent dequeue operations
|
||||
func TestQueueConcurrentDequeue(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(1000)
|
||||
|
||||
// Pre-fill queue
|
||||
for i := 0; i < 500; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i%10)), "team_1", 5, 100, 5)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
}
|
||||
|
||||
// Concurrently dequeue from multiple goroutines
|
||||
dequeued := make(chan *pool.QueueItem, 500)
|
||||
done := make(chan bool)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
for {
|
||||
item := pq.Dequeue()
|
||||
if item == nil {
|
||||
break
|
||||
}
|
||||
dequeued <- item
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
close(dequeued)
|
||||
|
||||
// Count dequeued items
|
||||
count := 0
|
||||
for range dequeued {
|
||||
count++
|
||||
}
|
||||
|
||||
assert.Equal(t, 500, count)
|
||||
assert.Equal(t, 0, pq.Size())
|
||||
}
|
||||
|
||||
// TestQueueConcurrentEnqueueDequeue tests concurrent enqueue and dequeue
|
||||
func TestQueueConcurrentEnqueueDequeue(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
// Run for a short time with concurrent operations
|
||||
done := make(chan bool)
|
||||
|
||||
// Enqueue goroutine
|
||||
go func() {
|
||||
for i := 0; i < 200; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i%10)), "team_1", 5, 50, 5)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Dequeue goroutine
|
||||
dequeueCount := 0
|
||||
go func() {
|
||||
for i := 0; i < 200; i++ {
|
||||
if pq.Dequeue() != nil {
|
||||
dequeueCount++
|
||||
}
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for both
|
||||
<-done
|
||||
<-done
|
||||
|
||||
// Should have processed some items (exact count depends on timing)
|
||||
assert.GreaterOrEqual(t, dequeueCount, 1)
|
||||
}
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
|
||||
// TestQueueIsFull tests IsFull method
|
||||
func TestQueueIsFull(t *testing.T) {
|
||||
t.Run("not full initially", func(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(5)
|
||||
assert.False(t, pq.IsFull())
|
||||
})
|
||||
|
||||
t.Run("full when at max", func(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(3)
|
||||
for i := 0; i < 3; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
}
|
||||
assert.True(t, pq.IsFull())
|
||||
})
|
||||
|
||||
t.Run("not full after dequeue", func(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(3)
|
||||
for i := 0; i < 3; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
}
|
||||
pq.Dequeue()
|
||||
assert.False(t, pq.IsFull())
|
||||
})
|
||||
|
||||
t.Run("never full when unlimited", func(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(0)
|
||||
for i := 0; i < 100; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i%26)), "team_1", 5, 1000, 5)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
}
|
||||
assert.False(t, pq.IsFull())
|
||||
})
|
||||
}
|
||||
|
||||
// TestQueueRobotQueuedCount tests RobotQueuedCount method
|
||||
func TestQueueRobotQueuedCount(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
|
||||
t.Run("zero for unknown robot", func(t *testing.T) {
|
||||
assert.Equal(t, 0, pq.RobotQueuedCount("unknown_robot"))
|
||||
})
|
||||
|
||||
t.Run("correct count for robot", func(t *testing.T) {
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
assert.Equal(t, 2, pq.RobotQueuedCount("robot_1"))
|
||||
})
|
||||
|
||||
t.Run("zero after all dequeued", func(t *testing.T) {
|
||||
robot := createTestRobot("robot_2", "team_1", 5, 10, 5)
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
pq.Dequeue()
|
||||
pq.Dequeue() // dequeue robot_1's items too
|
||||
pq.Dequeue()
|
||||
assert.Equal(t, 0, pq.RobotQueuedCount("robot_2"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestQueueEnqueueSetsEnqueueTime tests that EnqueueTime is set on enqueue
|
||||
func TestQueueEnqueueSetsEnqueueTime(t *testing.T) {
|
||||
pq := pool.NewPriorityQueue(100)
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
before := time.Now()
|
||||
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
|
||||
after := time.Now()
|
||||
|
||||
item := pq.Dequeue()
|
||||
assert.True(t, item.EnqueueTime.After(before) || item.EnqueueTime.Equal(before))
|
||||
assert.True(t, item.EnqueueTime.Before(after) || item.EnqueueTime.Equal(after))
|
||||
}
|
||||
112
agent/robot/pool/worker.go
Normal file
112
agent/robot/pool/worker.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Worker represents a worker goroutine that processes jobs
|
||||
type Worker struct {
|
||||
id int
|
||||
pool *Pool
|
||||
executor types.Executor
|
||||
stopChan chan struct{}
|
||||
wg *sync.WaitGroup
|
||||
}
|
||||
|
||||
// newWorker creates a new worker
|
||||
func newWorker(id int, pool *Pool, executor types.Executor, wg *sync.WaitGroup) *Worker {
|
||||
return &Worker{
|
||||
id: id,
|
||||
pool: pool,
|
||||
executor: executor,
|
||||
stopChan: make(chan struct{}),
|
||||
wg: wg,
|
||||
}
|
||||
}
|
||||
|
||||
// start starts the worker goroutine
|
||||
func (w *Worker) start() {
|
||||
w.wg.Add(1)
|
||||
go w.run()
|
||||
}
|
||||
|
||||
// stop signals the worker to stop
|
||||
func (w *Worker) stop() {
|
||||
close(w.stopChan)
|
||||
}
|
||||
|
||||
// run is the main worker loop
|
||||
func (w *Worker) run() {
|
||||
defer w.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond) // poll queue every 100ms
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopChan:
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
// Try to get a job from the queue
|
||||
item := w.pool.queue.Dequeue()
|
||||
if item == nil {
|
||||
continue // queue empty, wait for next tick
|
||||
}
|
||||
|
||||
// Execute the job
|
||||
w.execute(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// execute processes a single queue item
|
||||
func (w *Worker) execute(item *QueueItem) {
|
||||
// Pre-check if robot can run (non-atomic, just for early rejection)
|
||||
// The actual atomic check happens inside Executor.Execute() via TryAcquireSlot()
|
||||
if !item.Robot.CanRun() {
|
||||
// Robot likely at quota, re-enqueue for later
|
||||
w.requeue(item, "quota pre-check failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as running (only when actually executing)
|
||||
w.pool.incrementRunning()
|
||||
defer w.pool.decrementRunning()
|
||||
|
||||
// Execute via Executor interface
|
||||
// Note: Executor.Execute() does atomic quota check via TryAcquireSlot()
|
||||
execution, err := w.executor.Execute(item.Ctx, item.Robot, item.Trigger, item.Data)
|
||||
|
||||
if err != nil {
|
||||
// Check if it's a quota error (race condition - another worker got the slot)
|
||||
if err == types.ErrQuotaExceeded {
|
||||
w.requeue(item, "quota exceeded (race)")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Worker %d: Execution failed for robot %s: %v\n",
|
||||
w.id, item.Robot.MemberID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if execution != nil {
|
||||
fmt.Printf("Worker %d: Execution %s completed for robot %s (status: %s)\n",
|
||||
w.id, execution.ID, item.Robot.MemberID, execution.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// requeue attempts to put the item back in the queue
|
||||
func (w *Worker) requeue(item *QueueItem, reason string) {
|
||||
// Queue length is our system load threshold:
|
||||
// - If queue has space: task waits for robot quota
|
||||
// - If queue is full: system is overloaded, drop task
|
||||
if !w.pool.queue.Enqueue(item) {
|
||||
// Queue full = system overloaded, drop task (protective discard)
|
||||
fmt.Printf("Worker %d: Task for robot %s dropped (queue full, %s)\n",
|
||||
w.id, item.Robot.MemberID, reason)
|
||||
}
|
||||
}
|
||||
435
agent/robot/pool/worker_test.go
Normal file
435
agent/robot/pool/worker_test.go
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
package pool_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/pool"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ==================== Worker Basic Tests ====================
|
||||
|
||||
// TestWorkerExecutesJob tests that worker executes a job from queue
|
||||
func TestWorkerExecutesJob(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Submit job
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
|
||||
// Wait for execution
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
assert.Equal(t, 1, exec.ExecCount())
|
||||
}
|
||||
|
||||
// TestWorkerMultipleJobs tests worker processes multiple jobs sequentially
|
||||
func TestWorkerMultipleJobs(t *testing.T) {
|
||||
exec := executor.NewWithDelay(20 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // single worker
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 10, 10, 5)
|
||||
|
||||
// Submit 3 jobs
|
||||
for i := 0; i < 3; i++ {
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for all executions (worker polls every 100ms, each job takes 20ms)
|
||||
// Need: 3 polls * 100ms + 3 jobs * 20ms = ~360ms, add buffer
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
assert.Equal(t, 3, exec.ExecCount())
|
||||
}
|
||||
|
||||
// ==================== Worker Quota Check Tests ====================
|
||||
|
||||
// TestWorkerRespectsRobotQuota tests worker re-enqueues when robot quota is full
|
||||
func TestWorkerRespectsRobotQuota(t *testing.T) {
|
||||
// This test verifies that all jobs eventually complete even when robot quota limits concurrency
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5, // multiple workers
|
||||
QueueSize: 20,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
// Robot can only run 2 at a time
|
||||
robot := createTestRobot("robot_limited", "team_1", 2, 10, 5)
|
||||
|
||||
// Submit 5 jobs for same robot
|
||||
for i := 0; i < 5; i++ {
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
// With Quota.Max=2, jobs execute in batches: 2+2+1 = 3 batches
|
||||
// Each batch: 100ms exec + 100ms poll = ~200ms, total ~600ms, add buffer
|
||||
time.Sleep(1000 * time.Millisecond)
|
||||
|
||||
// All should eventually execute
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 5, "All jobs should eventually execute")
|
||||
}
|
||||
|
||||
// TestWorkerReenqueueOnQuotaFull tests that jobs are re-enqueued when quota is full
|
||||
func TestWorkerReenqueueOnQuotaFull(t *testing.T) {
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3,
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
// Robot can only run 1 at a time, but large queue
|
||||
robot := createTestRobot("robot_1", "team_1", 1, 50, 5)
|
||||
|
||||
// Submit 5 jobs
|
||||
for i := 0; i < 5; i++ {
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
|
||||
// All 5 should eventually execute
|
||||
assert.Equal(t, 5, exec.ExecCount())
|
||||
}
|
||||
|
||||
// ==================== Worker Concurrency Tests ====================
|
||||
|
||||
// TestWorkersConcurrentExecution tests multiple workers execute concurrently
|
||||
func TestWorkersConcurrentExecution(t *testing.T) {
|
||||
// Track max concurrent executions
|
||||
var maxConcurrent int32
|
||||
var currentConcurrent int32
|
||||
|
||||
exec := executor.NewWithCallback(100*time.Millisecond, func() {
|
||||
current := atomic.AddInt32(¤tConcurrent, 1)
|
||||
// Update max if current is higher
|
||||
for {
|
||||
max := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= max || atomic.CompareAndSwapInt32(&maxConcurrent, max, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}, func() {
|
||||
atomic.AddInt32(¤tConcurrent, -1)
|
||||
})
|
||||
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5, // 5 workers
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Submit 10 jobs for different robots
|
||||
for i := 0; i < 10; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for execution
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
// Should have had concurrent execution (max > 1)
|
||||
assert.GreaterOrEqual(t, atomic.LoadInt32(&maxConcurrent), int32(2), "Should have concurrent execution")
|
||||
}
|
||||
|
||||
// TestWorkersDoNotExceedPoolSize tests workers don't exceed pool size
|
||||
func TestWorkersDoNotExceedPoolSize(t *testing.T) {
|
||||
var maxConcurrent int32
|
||||
var currentConcurrent int32
|
||||
var mu sync.Mutex
|
||||
|
||||
exec := executor.NewWithCallback(50*time.Millisecond, func() {
|
||||
mu.Lock()
|
||||
currentConcurrent++
|
||||
if currentConcurrent > maxConcurrent {
|
||||
maxConcurrent = currentConcurrent
|
||||
}
|
||||
mu.Unlock()
|
||||
}, func() {
|
||||
mu.Lock()
|
||||
currentConcurrent--
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3, // only 3 workers
|
||||
QueueSize: 100,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Submit 20 jobs
|
||||
for i := 0; i < 20; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Max concurrent should not exceed worker size
|
||||
assert.LessOrEqual(t, maxConcurrent, int32(3), "Should not exceed worker size")
|
||||
}
|
||||
|
||||
// ==================== Worker Stop Tests ====================
|
||||
|
||||
// TestWorkerStopsGracefully tests worker stops when signaled
|
||||
func TestWorkerStopsGracefully(t *testing.T) {
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 2,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Submit jobs
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
|
||||
// Wait for jobs to start
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Stop pool
|
||||
err := p.Stop()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Pool should be stopped
|
||||
assert.False(t, p.IsStarted())
|
||||
}
|
||||
|
||||
// TestWorkerCompletesCurrentJobOnStop tests worker completes current job before stopping
|
||||
func TestWorkerCompletesCurrentJobOnStop(t *testing.T) {
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Submit job
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
|
||||
// Wait for job to start
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Stop pool - should wait for current job
|
||||
p.Stop()
|
||||
|
||||
// Job should have completed
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 1)
|
||||
}
|
||||
|
||||
// ==================== Worker Error Handling Tests ====================
|
||||
|
||||
// TestWorkerHandlesExecutorError tests worker continues after executor error
|
||||
func TestWorkerHandlesExecutorError(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Submit job that will fail (using special data)
|
||||
p.Submit(ctx, robot, types.TriggerClock, "simulate_failure")
|
||||
|
||||
// Submit another job that should succeed
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
|
||||
// Wait for execution
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Both should have been attempted
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 2)
|
||||
}
|
||||
|
||||
// ==================== Worker Running Counter Tests ====================
|
||||
|
||||
// TestWorkerRunningCounterAccurate tests running counter is accurate
|
||||
func TestWorkerRunningCounterAccurate(t *testing.T) {
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
|
||||
// Submit jobs for different robots
|
||||
for i := 0; i < 3; i++ {
|
||||
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
}
|
||||
|
||||
// Wait for jobs to start
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Running should be > 0
|
||||
running := p.Running()
|
||||
assert.GreaterOrEqual(t, running, 1)
|
||||
|
||||
// Wait for completion
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Running should be 0 after completion
|
||||
assert.Equal(t, 0, p.Running())
|
||||
}
|
||||
|
||||
// TestWorkerRunningCounterDecrementsOnError tests running counter decrements on error
|
||||
func TestWorkerRunningCounterDecrementsOnError(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Submit failing job
|
||||
p.Submit(ctx, robot, types.TriggerClock, "simulate_failure")
|
||||
|
||||
// Wait for execution
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Running should be 0 (decremented even on error)
|
||||
assert.Equal(t, 0, p.Running())
|
||||
}
|
||||
|
||||
// ==================== Worker with Different Trigger Types ====================
|
||||
|
||||
// TestWorkerProcessesDifferentTriggers tests worker handles all trigger types
|
||||
func TestWorkerProcessesDifferentTriggers(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Submit different trigger types
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
p.Submit(ctx, robot, types.TriggerHuman, nil)
|
||||
p.Submit(ctx, robot, types.TriggerEvent, nil)
|
||||
|
||||
// Wait for execution (worker polls every 100ms, each job takes 10ms)
|
||||
// Need: 3 polls * 100ms + 3 jobs * 10ms = ~330ms, add buffer
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// All should execute
|
||||
assert.Equal(t, 3, exec.ExecCount())
|
||||
}
|
||||
|
||||
// ==================== Worker Polling Behavior Tests ====================
|
||||
|
||||
// TestWorkerPollsQueuePeriodically tests worker polls queue at regular intervals
|
||||
func TestWorkerPollsQueuePeriodically(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Submit job after pool started
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
|
||||
// Worker should pick up job within poll interval (100ms)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
assert.Equal(t, 1, exec.ExecCount())
|
||||
}
|
||||
|
||||
// TestWorkerContinuesAfterEmptyQueue tests worker continues polling after empty queue
|
||||
func TestWorkerContinuesAfterEmptyQueue(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
})
|
||||
p.SetExecutor(exec)
|
||||
p.Start()
|
||||
defer p.Stop()
|
||||
|
||||
ctx := createTestContext()
|
||||
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
|
||||
|
||||
// Wait with empty queue
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Submit job
|
||||
p.Submit(ctx, robot, types.TriggerClock, nil)
|
||||
|
||||
// Worker should still be running and pick up job
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
assert.Equal(t, 1, exec.ExecCount())
|
||||
}
|
||||
53
agent/robot/robot.go
Normal file
53
agent/robot/robot.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/robot/cache"
|
||||
"github.com/yaoapp/yao/agent/robot/dedup"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/manager"
|
||||
"github.com/yaoapp/yao/agent/robot/plan"
|
||||
"github.com/yaoapp/yao/agent/robot/pool"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
"github.com/yaoapp/yao/agent/robot/trigger"
|
||||
)
|
||||
|
||||
var (
|
||||
// Global instances (will be initialized in Init)
|
||||
globalManager *manager.Manager
|
||||
globalCache *cache.Cache
|
||||
globalPool *pool.Pool
|
||||
globalDedup *dedup.Dedup
|
||||
globalStore *store.Store
|
||||
globalTrigger *trigger.Trigger
|
||||
globalExecutor *executor.Executor
|
||||
globalPlan *plan.Plan
|
||||
)
|
||||
|
||||
// Init initializes the robot agent system
|
||||
// Stub: placeholder (will be implemented in Phase 3)
|
||||
func Init() error {
|
||||
// Initialize global instances
|
||||
globalCache = cache.New()
|
||||
globalDedup = dedup.New()
|
||||
globalStore = store.New()
|
||||
globalPool = pool.New() // Default pool size
|
||||
globalTrigger = trigger.New()
|
||||
globalExecutor = executor.New()
|
||||
globalManager = manager.New()
|
||||
globalPlan = plan.New()
|
||||
|
||||
// TODO Phase 3: Start manager and pool
|
||||
// return globalManager.Start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the robot agent system
|
||||
// Stub: placeholder (will be implemented in Phase 3)
|
||||
func Shutdown() error {
|
||||
// TODO Phase 3: Stop manager and pool
|
||||
// if globalManager != nil {
|
||||
// return globalManager.Stop()
|
||||
// }
|
||||
return nil
|
||||
}
|
||||
36
agent/robot/store/store.go
Normal file
36
agent/robot/store/store.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package store
|
||||
|
||||
import "github.com/yaoapp/yao/agent/robot/types"
|
||||
|
||||
// Store implements types.Store interface
|
||||
// This is a stub implementation for Phase 2
|
||||
type Store struct{}
|
||||
|
||||
// New creates a new store instance
|
||||
func New() *Store {
|
||||
return &Store{}
|
||||
}
|
||||
|
||||
// SaveLearning saves learning entries to private KB
|
||||
// Stub: returns nil (will be implemented in Phase 9)
|
||||
func (s *Store) SaveLearning(ctx *types.Context, memberID string, entries []types.LearningEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHistory retrieves learning history from private KB
|
||||
// Stub: returns empty slice (will be implemented in Phase 9)
|
||||
func (s *Store) GetHistory(ctx *types.Context, memberID string, limit int) ([]types.LearningEntry, error) {
|
||||
return []types.LearningEntry{}, nil
|
||||
}
|
||||
|
||||
// SearchKB searches knowledge base collections
|
||||
// Stub: returns empty slice (will be implemented in Phase 4+)
|
||||
func (s *Store) SearchKB(ctx *types.Context, collections []string, query string) ([]interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
// QueryDB queries database models
|
||||
// Stub: returns empty slice (will be implemented in Phase 4+)
|
||||
func (s *Store) QueryDB(ctx *types.Context, models []string, query interface{}) ([]interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
48
agent/robot/trigger/trigger.go
Normal file
48
agent/robot/trigger/trigger.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package trigger
|
||||
|
||||
import "github.com/yaoapp/yao/agent/robot/types"
|
||||
|
||||
// Trigger handles all trigger sources
|
||||
// This is a stub implementation for Phase 2
|
||||
type Trigger struct{}
|
||||
|
||||
// New creates a new trigger instance
|
||||
func New() *Trigger {
|
||||
return &Trigger{}
|
||||
}
|
||||
|
||||
// Clock processes clock trigger
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (t *Trigger) Clock(ctx *types.Context, robot *types.Robot) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intervene processes human intervention
|
||||
// Stub: returns empty result (will be implemented in Phase 3)
|
||||
func (t *Trigger) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
|
||||
return &types.ExecutionResult{}, nil
|
||||
}
|
||||
|
||||
// Event processes event trigger
|
||||
// Stub: returns empty result (will be implemented in Phase 3)
|
||||
func (t *Trigger) Event(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
|
||||
return &types.ExecutionResult{}, nil
|
||||
}
|
||||
|
||||
// Pause pauses a running execution
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (t *Trigger) Pause(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume resumes a paused execution
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (t *Trigger) Resume(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops a running execution
|
||||
// Stub: returns nil (will be implemented in Phase 3)
|
||||
func (t *Trigger) Stop(ctx *types.Context, execID string) error {
|
||||
return nil
|
||||
}
|
||||
51
agent/robot/types/clock.go
Normal file
51
agent/robot/types/clock.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// ClockContext - time context for P0 inspiration
|
||||
type ClockContext struct {
|
||||
Now time.Time `json:"now"`
|
||||
Hour int `json:"hour"` // 0-23
|
||||
DayOfWeek string `json:"day_of_week"` // Monday, Tuesday...
|
||||
DayOfMonth int `json:"day_of_month"` // 1-31
|
||||
WeekOfYear int `json:"week_of_year"` // 1-52
|
||||
Month int `json:"month"` // 1-12
|
||||
Year int `json:"year"`
|
||||
IsWeekend bool `json:"is_weekend"`
|
||||
IsMonthStart bool `json:"is_month_start"` // 1st-3rd
|
||||
IsMonthEnd bool `json:"is_month_end"` // last 3 days
|
||||
IsQuarterEnd bool `json:"is_quarter_end"`
|
||||
IsYearEnd bool `json:"is_year_end"`
|
||||
TZ string `json:"tz"`
|
||||
}
|
||||
|
||||
// NewClockContext creates clock context from time
|
||||
func NewClockContext(t time.Time, tz string) *ClockContext {
|
||||
loc := time.Local
|
||||
if tz != "" {
|
||||
if l, err := time.LoadLocation(tz); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
t = t.In(loc)
|
||||
|
||||
_, week := t.ISOWeek()
|
||||
dayOfMonth := t.Day()
|
||||
lastDay := time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, loc).Day()
|
||||
|
||||
return &ClockContext{
|
||||
Now: t,
|
||||
Hour: t.Hour(),
|
||||
DayOfWeek: t.Weekday().String(),
|
||||
DayOfMonth: dayOfMonth,
|
||||
WeekOfYear: week,
|
||||
Month: int(t.Month()),
|
||||
Year: t.Year(),
|
||||
IsWeekend: t.Weekday() == time.Saturday || t.Weekday() == time.Sunday,
|
||||
IsMonthStart: dayOfMonth <= 3,
|
||||
IsMonthEnd: dayOfMonth >= lastDay-2,
|
||||
IsQuarterEnd: (t.Month()%3 == 0) && dayOfMonth >= lastDay-2,
|
||||
IsYearEnd: t.Month() == 12 && dayOfMonth >= 29,
|
||||
TZ: loc.String(),
|
||||
}
|
||||
}
|
||||
171
agent/robot/types/clock_test.go
Normal file
171
agent/robot/types/clock_test.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
func TestNewClockContext(t *testing.T) {
|
||||
t.Run("basic clock context", func(t *testing.T) {
|
||||
// Test with a known date: 2024-01-15 14:30:00 (Monday)
|
||||
testTime := time.Date(2024, 1, 15, 14, 30, 0, 0, time.UTC)
|
||||
ctx := types.NewClockContext(testTime, "UTC")
|
||||
|
||||
assert.Equal(t, 14, ctx.Hour)
|
||||
assert.Equal(t, "Monday", ctx.DayOfWeek)
|
||||
assert.Equal(t, 15, ctx.DayOfMonth)
|
||||
assert.Equal(t, 1, ctx.Month)
|
||||
assert.Equal(t, 2024, ctx.Year)
|
||||
assert.False(t, ctx.IsWeekend)
|
||||
assert.False(t, ctx.IsMonthStart)
|
||||
assert.False(t, ctx.IsMonthEnd)
|
||||
assert.False(t, ctx.IsQuarterEnd)
|
||||
assert.False(t, ctx.IsYearEnd)
|
||||
})
|
||||
|
||||
t.Run("weekend detection", func(t *testing.T) {
|
||||
// Saturday
|
||||
saturday := time.Date(2024, 1, 13, 10, 0, 0, 0, time.UTC)
|
||||
ctx := types.NewClockContext(saturday, "")
|
||||
assert.True(t, ctx.IsWeekend)
|
||||
|
||||
// Sunday
|
||||
sunday := time.Date(2024, 1, 14, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(sunday, "")
|
||||
assert.True(t, ctx.IsWeekend)
|
||||
})
|
||||
|
||||
t.Run("month start detection", func(t *testing.T) {
|
||||
// 1st day
|
||||
day1 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
ctx := types.NewClockContext(day1, "")
|
||||
assert.True(t, ctx.IsMonthStart)
|
||||
|
||||
// 3rd day
|
||||
day3 := time.Date(2024, 1, 3, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(day3, "")
|
||||
assert.True(t, ctx.IsMonthStart)
|
||||
|
||||
// 4th day - not month start
|
||||
day4 := time.Date(2024, 1, 4, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(day4, "")
|
||||
assert.False(t, ctx.IsMonthStart)
|
||||
})
|
||||
|
||||
t.Run("month end detection", func(t *testing.T) {
|
||||
// Last day of January (31st)
|
||||
lastDay := time.Date(2024, 1, 31, 10, 0, 0, 0, time.UTC)
|
||||
ctx := types.NewClockContext(lastDay, "")
|
||||
assert.True(t, ctx.IsMonthEnd)
|
||||
|
||||
// 29th day of January (31 days total)
|
||||
day29 := time.Date(2024, 1, 29, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(day29, "")
|
||||
assert.True(t, ctx.IsMonthEnd)
|
||||
|
||||
// 28th day of January - not month end
|
||||
day28 := time.Date(2024, 1, 28, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(day28, "")
|
||||
assert.False(t, ctx.IsMonthEnd)
|
||||
})
|
||||
|
||||
t.Run("quarter end detection", func(t *testing.T) {
|
||||
// March 31 - Q1 end
|
||||
q1End := time.Date(2024, 3, 31, 10, 0, 0, 0, time.UTC)
|
||||
ctx := types.NewClockContext(q1End, "")
|
||||
assert.True(t, ctx.IsQuarterEnd)
|
||||
|
||||
// June 30 - Q2 end
|
||||
q2End := time.Date(2024, 6, 30, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(q2End, "")
|
||||
assert.True(t, ctx.IsQuarterEnd)
|
||||
|
||||
// September 30 - Q3 end
|
||||
q3End := time.Date(2024, 9, 30, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(q3End, "")
|
||||
assert.True(t, ctx.IsQuarterEnd)
|
||||
|
||||
// December 31 - Q4 end
|
||||
q4End := time.Date(2024, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(q4End, "")
|
||||
assert.True(t, ctx.IsQuarterEnd)
|
||||
|
||||
// Not quarter end
|
||||
notQEnd := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(notQEnd, "")
|
||||
assert.False(t, ctx.IsQuarterEnd)
|
||||
})
|
||||
|
||||
t.Run("year end detection", func(t *testing.T) {
|
||||
// December 29
|
||||
dec29 := time.Date(2024, 12, 29, 10, 0, 0, 0, time.UTC)
|
||||
ctx := types.NewClockContext(dec29, "")
|
||||
assert.True(t, ctx.IsYearEnd)
|
||||
|
||||
// December 31
|
||||
dec31 := time.Date(2024, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(dec31, "")
|
||||
assert.True(t, ctx.IsYearEnd)
|
||||
|
||||
// December 28 - not year end
|
||||
dec28 := time.Date(2024, 12, 28, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(dec28, "")
|
||||
assert.False(t, ctx.IsYearEnd)
|
||||
|
||||
// January - not year end
|
||||
jan1 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(jan1, "")
|
||||
assert.False(t, ctx.IsYearEnd)
|
||||
})
|
||||
|
||||
t.Run("timezone handling", func(t *testing.T) {
|
||||
testTime := time.Date(2024, 1, 15, 14, 30, 0, 0, time.UTC)
|
||||
|
||||
// With Asia/Shanghai timezone
|
||||
ctx := types.NewClockContext(testTime, "Asia/Shanghai")
|
||||
assert.Equal(t, "Asia/Shanghai", ctx.TZ)
|
||||
// Time should be converted to Shanghai timezone
|
||||
assert.NotEqual(t, testTime, ctx.Now)
|
||||
assert.Equal(t, 22, ctx.Hour) // UTC 14:00 = Shanghai 22:00 (UTC+8)
|
||||
|
||||
// With invalid timezone - should fall back to local
|
||||
ctx = types.NewClockContext(testTime, "Invalid/Timezone")
|
||||
assert.NotEmpty(t, ctx.TZ)
|
||||
})
|
||||
|
||||
t.Run("week of year", func(t *testing.T) {
|
||||
// First week of 2024
|
||||
jan1 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
ctx := types.NewClockContext(jan1, "")
|
||||
assert.Equal(t, 1, ctx.WeekOfYear)
|
||||
|
||||
// Mid year
|
||||
july15 := time.Date(2024, 7, 15, 10, 0, 0, 0, time.UTC)
|
||||
ctx = types.NewClockContext(july15, "")
|
||||
assert.Greater(t, ctx.WeekOfYear, 20)
|
||||
assert.Less(t, ctx.WeekOfYear, 35)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClockContextFields(t *testing.T) {
|
||||
// Test all fields are populated correctly
|
||||
testTime := time.Date(2024, 12, 30, 23, 45, 30, 0, time.UTC)
|
||||
ctx := types.NewClockContext(testTime, "UTC")
|
||||
|
||||
assert.NotZero(t, ctx.Now)
|
||||
assert.Equal(t, 23, ctx.Hour)
|
||||
assert.Equal(t, "Monday", ctx.DayOfWeek)
|
||||
assert.Equal(t, 30, ctx.DayOfMonth)
|
||||
assert.Equal(t, 1, ctx.WeekOfYear) // Dec 30, 2024 is week 1 of 2025
|
||||
assert.Equal(t, 12, ctx.Month)
|
||||
assert.Equal(t, 2024, ctx.Year)
|
||||
assert.False(t, ctx.IsWeekend) // Monday
|
||||
assert.False(t, ctx.IsMonthStart)
|
||||
assert.True(t, ctx.IsMonthEnd)
|
||||
assert.True(t, ctx.IsQuarterEnd)
|
||||
assert.True(t, ctx.IsYearEnd)
|
||||
assert.Equal(t, "UTC", ctx.TZ)
|
||||
}
|
||||
252
agent/robot/types/config.go
Normal file
252
agent/robot/types/config.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config - robot_config in __yao.member
|
||||
type Config struct {
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *Delivery `json:"delivery,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
// Validate validates the config
|
||||
func (c *Config) Validate() error {
|
||||
if c.Identity == nil || c.Identity.Role == "" {
|
||||
return ErrMissingIdentity
|
||||
}
|
||||
if c.Clock != nil {
|
||||
if err := c.Clock.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Triggers - trigger enable/disable
|
||||
type Triggers struct {
|
||||
Clock *TriggerSwitch `json:"clock,omitempty"`
|
||||
Intervene *TriggerSwitch `json:"intervene,omitempty"`
|
||||
Event *TriggerSwitch `json:"event,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerSwitch - trigger enable/disable switch
|
||||
type TriggerSwitch struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Actions []string `json:"actions,omitempty"` // for intervene
|
||||
}
|
||||
|
||||
// IsEnabled checks if trigger is enabled (default: true)
|
||||
func (t *Triggers) IsEnabled(typ TriggerType) bool {
|
||||
if t == nil {
|
||||
return true
|
||||
}
|
||||
switch typ {
|
||||
case TriggerClock:
|
||||
return t.Clock == nil || t.Clock.Enabled
|
||||
case TriggerHuman:
|
||||
return t.Intervene == nil || t.Intervene.Enabled
|
||||
case TriggerEvent:
|
||||
return t.Event == nil || t.Event.Enabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Clock - when to wake up
|
||||
type Clock struct {
|
||||
Mode ClockMode `json:"mode"` // times | interval | daemon
|
||||
Times []string `json:"times,omitempty"` // ["09:00", "14:00"]
|
||||
Days []string `json:"days,omitempty"` // ["Mon", "Tue"] or ["*"]
|
||||
Every string `json:"every,omitempty"` // "30m", "1h"
|
||||
TZ string `json:"tz,omitempty"` // "Asia/Shanghai"
|
||||
Timeout string `json:"timeout,omitempty"` // "30m"
|
||||
}
|
||||
|
||||
// Validate validates clock config
|
||||
func (c *Clock) Validate() error {
|
||||
switch c.Mode {
|
||||
case ClockTimes:
|
||||
if len(c.Times) == 0 {
|
||||
return ErrClockTimesEmpty
|
||||
}
|
||||
case ClockInterval:
|
||||
if c.Every == "" {
|
||||
return ErrClockIntervalEmpty
|
||||
}
|
||||
case ClockDaemon:
|
||||
// no extra validation
|
||||
default:
|
||||
return ErrClockModeInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTimeout returns parsed timeout duration
|
||||
func (c *Clock) GetTimeout() time.Duration {
|
||||
if c.Timeout == "" {
|
||||
return 30 * time.Minute // default
|
||||
}
|
||||
d, err := time.ParseDuration(c.Timeout)
|
||||
if err != nil {
|
||||
return 30 * time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// GetLocation returns timezone location
|
||||
func (c *Clock) GetLocation() *time.Location {
|
||||
if c.TZ == "" {
|
||||
return time.Local
|
||||
}
|
||||
loc, err := time.LoadLocation(c.TZ)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// Identity - who is this robot
|
||||
type Identity struct {
|
||||
Role string `json:"role"`
|
||||
Duties []string `json:"duties,omitempty"`
|
||||
Rules []string `json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
// Quota - concurrency limits
|
||||
type Quota struct {
|
||||
Max int `json:"max"` // max running (default: 2)
|
||||
Queue int `json:"queue"` // queue size (default: 10)
|
||||
Priority int `json:"priority"` // 1-10 (default: 5)
|
||||
}
|
||||
|
||||
// GetMax returns max with default
|
||||
func (q *Quota) GetMax() int {
|
||||
if q == nil || q.Max <= 0 {
|
||||
return 2
|
||||
}
|
||||
return q.Max
|
||||
}
|
||||
|
||||
// GetQueue returns queue size with default
|
||||
func (q *Quota) GetQueue() int {
|
||||
if q == nil || q.Queue <= 0 {
|
||||
return 10
|
||||
}
|
||||
return q.Queue
|
||||
}
|
||||
|
||||
// GetPriority returns priority with default
|
||||
func (q *Quota) GetPriority() int {
|
||||
if q == nil || q.Priority <= 0 {
|
||||
return 5
|
||||
}
|
||||
return q.Priority
|
||||
}
|
||||
|
||||
// KB - knowledge base config (same as assistant, from store/types)
|
||||
// Shared KB collections accessible by this robot
|
||||
type KB struct {
|
||||
Collections []string `json:"collections,omitempty"` // KB collection IDs
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// DB - database config (same as assistant, from store/types)
|
||||
// Shared database models accessible by this robot
|
||||
type DB struct {
|
||||
Models []string `json:"models,omitempty"` // database model names
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// Learn - learning config for robot's private KB
|
||||
// Private KB is auto-created: robot_{team_id}_{member_id}_kb
|
||||
type Learn struct {
|
||||
On bool `json:"on"`
|
||||
Types []string `json:"types,omitempty"` // execution, feedback, insight
|
||||
Keep int `json:"keep,omitempty"` // days, 0 = forever
|
||||
}
|
||||
|
||||
// Resources - available agents and tools
|
||||
type Resources struct {
|
||||
Phases map[Phase]string `json:"phases,omitempty"` // phase -> agent ID
|
||||
Agents []string `json:"agents,omitempty"`
|
||||
MCP []MCPConfig `json:"mcp,omitempty"`
|
||||
}
|
||||
|
||||
// GetPhaseAgent returns agent ID for phase (default: __yao.{phase})
|
||||
func (r *Resources) GetPhaseAgent(phase Phase) string {
|
||||
if r != nil && r.Phases != nil {
|
||||
if id, ok := r.Phases[phase]; ok && id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return "__yao." + string(phase)
|
||||
}
|
||||
|
||||
// MCPConfig - MCP server configuration
|
||||
type MCPConfig struct {
|
||||
ID string `json:"id"`
|
||||
Tools []string `json:"tools,omitempty"` // empty = all
|
||||
}
|
||||
|
||||
// Delivery - output delivery
|
||||
type Delivery struct {
|
||||
Type DeliveryType `json:"type"`
|
||||
Opts map[string]interface{} `json:"opts,omitempty"`
|
||||
}
|
||||
|
||||
// Event - event trigger config
|
||||
type Event struct {
|
||||
Type EventSource `json:"type"` // webhook | database
|
||||
Source string `json:"source"` // webhook path or table name
|
||||
Filter map[string]interface{} `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// ParseConfig parses robot_config from various formats (string, []byte, map)
|
||||
func ParseConfig(data interface{}) (*Config, error) {
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var configBytes []byte
|
||||
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
configBytes = []byte(v)
|
||||
case []byte:
|
||||
if len(v) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
configBytes = v
|
||||
case map[string]interface{}:
|
||||
var err error
|
||||
configBytes, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
var err error
|
||||
configBytes, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(configBytes, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
252
agent/robot/types/config_test.go
Normal file
252
agent/robot/types/config_test.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
config := &types.Config{
|
||||
Identity: &types.Identity{
|
||||
Role: "Sales Manager",
|
||||
},
|
||||
}
|
||||
err := config.Validate()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("missing identity", func(t *testing.T) {
|
||||
config := &types.Config{}
|
||||
err := config.Validate()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrMissingIdentity, err)
|
||||
})
|
||||
|
||||
t.Run("missing identity role", func(t *testing.T) {
|
||||
config := &types.Config{
|
||||
Identity: &types.Identity{},
|
||||
}
|
||||
err := config.Validate()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrMissingIdentity, err)
|
||||
})
|
||||
|
||||
t.Run("invalid clock config", func(t *testing.T) {
|
||||
config := &types.Config{
|
||||
Identity: &types.Identity{Role: "Test"},
|
||||
Clock: &types.Clock{
|
||||
Mode: types.ClockTimes,
|
||||
// Times is empty - should fail
|
||||
},
|
||||
}
|
||||
err := config.Validate()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrClockTimesEmpty, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClockValidate(t *testing.T) {
|
||||
t.Run("valid times mode", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
Mode: types.ClockTimes,
|
||||
Times: []string{"09:00", "14:00"},
|
||||
}
|
||||
err := clock.Validate()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("times mode without times", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
Mode: types.ClockTimes,
|
||||
}
|
||||
err := clock.Validate()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrClockTimesEmpty, err)
|
||||
})
|
||||
|
||||
t.Run("valid interval mode", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
Mode: types.ClockInterval,
|
||||
Every: "30m",
|
||||
}
|
||||
err := clock.Validate()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("interval mode without every", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
Mode: types.ClockInterval,
|
||||
}
|
||||
err := clock.Validate()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrClockIntervalEmpty, err)
|
||||
})
|
||||
|
||||
t.Run("valid daemon mode", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
Mode: types.ClockDaemon,
|
||||
}
|
||||
err := clock.Validate()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid mode", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
Mode: types.ClockMode("invalid"),
|
||||
}
|
||||
err := clock.Validate()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, types.ErrClockModeInvalid, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClockGetTimeout(t *testing.T) {
|
||||
t.Run("default timeout", func(t *testing.T) {
|
||||
clock := &types.Clock{}
|
||||
timeout := clock.GetTimeout()
|
||||
assert.Equal(t, 30*time.Minute, timeout)
|
||||
})
|
||||
|
||||
t.Run("custom timeout", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
Timeout: "10m",
|
||||
}
|
||||
timeout := clock.GetTimeout()
|
||||
assert.Equal(t, 10*time.Minute, timeout)
|
||||
})
|
||||
|
||||
t.Run("invalid timeout returns default", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
Timeout: "invalid",
|
||||
}
|
||||
timeout := clock.GetTimeout()
|
||||
assert.Equal(t, 30*time.Minute, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClockGetLocation(t *testing.T) {
|
||||
t.Run("default location", func(t *testing.T) {
|
||||
clock := &types.Clock{}
|
||||
loc := clock.GetLocation()
|
||||
assert.Equal(t, time.Local, loc)
|
||||
})
|
||||
|
||||
t.Run("valid timezone", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
TZ: "Asia/Shanghai",
|
||||
}
|
||||
loc := clock.GetLocation()
|
||||
assert.NotNil(t, loc)
|
||||
assert.Equal(t, "Asia/Shanghai", loc.String())
|
||||
})
|
||||
|
||||
t.Run("invalid timezone returns local", func(t *testing.T) {
|
||||
clock := &types.Clock{
|
||||
TZ: "Invalid/Timezone",
|
||||
}
|
||||
loc := clock.GetLocation()
|
||||
assert.Equal(t, time.Local, loc)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTriggersIsEnabled(t *testing.T) {
|
||||
t.Run("nil triggers - all enabled by default", func(t *testing.T) {
|
||||
var triggers *types.Triggers
|
||||
assert.True(t, triggers.IsEnabled(types.TriggerClock))
|
||||
assert.True(t, triggers.IsEnabled(types.TriggerHuman))
|
||||
assert.True(t, triggers.IsEnabled(types.TriggerEvent))
|
||||
})
|
||||
|
||||
t.Run("clock enabled", func(t *testing.T) {
|
||||
triggers := &types.Triggers{
|
||||
Clock: &types.TriggerSwitch{Enabled: true},
|
||||
}
|
||||
assert.True(t, triggers.IsEnabled(types.TriggerClock))
|
||||
})
|
||||
|
||||
t.Run("clock disabled", func(t *testing.T) {
|
||||
triggers := &types.Triggers{
|
||||
Clock: &types.TriggerSwitch{Enabled: false},
|
||||
}
|
||||
assert.False(t, triggers.IsEnabled(types.TriggerClock))
|
||||
})
|
||||
|
||||
t.Run("intervene enabled by default", func(t *testing.T) {
|
||||
triggers := &types.Triggers{}
|
||||
assert.True(t, triggers.IsEnabled(types.TriggerHuman))
|
||||
})
|
||||
|
||||
t.Run("event disabled", func(t *testing.T) {
|
||||
triggers := &types.Triggers{
|
||||
Event: &types.TriggerSwitch{Enabled: false},
|
||||
}
|
||||
assert.False(t, triggers.IsEnabled(types.TriggerEvent))
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuotaDefaults(t *testing.T) {
|
||||
t.Run("nil quota", func(t *testing.T) {
|
||||
var quota *types.Quota
|
||||
assert.Equal(t, 2, quota.GetMax())
|
||||
assert.Equal(t, 10, quota.GetQueue())
|
||||
assert.Equal(t, 5, quota.GetPriority())
|
||||
})
|
||||
|
||||
t.Run("zero values", func(t *testing.T) {
|
||||
quota := &types.Quota{}
|
||||
assert.Equal(t, 2, quota.GetMax())
|
||||
assert.Equal(t, 10, quota.GetQueue())
|
||||
assert.Equal(t, 5, quota.GetPriority())
|
||||
})
|
||||
|
||||
t.Run("custom values", func(t *testing.T) {
|
||||
quota := &types.Quota{
|
||||
Max: 5,
|
||||
Queue: 20,
|
||||
Priority: 8,
|
||||
}
|
||||
assert.Equal(t, 5, quota.GetMax())
|
||||
assert.Equal(t, 20, quota.GetQueue())
|
||||
assert.Equal(t, 8, quota.GetPriority())
|
||||
})
|
||||
}
|
||||
|
||||
func TestResourcesGetPhaseAgent(t *testing.T) {
|
||||
t.Run("nil resources - returns default", func(t *testing.T) {
|
||||
var resources *types.Resources
|
||||
agent := resources.GetPhaseAgent(types.PhaseGoals)
|
||||
assert.Equal(t, "__yao.goals", agent)
|
||||
})
|
||||
|
||||
t.Run("phase not configured - returns default", func(t *testing.T) {
|
||||
resources := &types.Resources{
|
||||
Phases: map[types.Phase]string{},
|
||||
}
|
||||
agent := resources.GetPhaseAgent(types.PhaseGoals)
|
||||
assert.Equal(t, "__yao.goals", agent)
|
||||
})
|
||||
|
||||
t.Run("custom phase agent", func(t *testing.T) {
|
||||
resources := &types.Resources{
|
||||
Phases: map[types.Phase]string{
|
||||
types.PhaseGoals: "custom.goals.agent",
|
||||
},
|
||||
}
|
||||
agent := resources.GetPhaseAgent(types.PhaseGoals)
|
||||
assert.Equal(t, "custom.goals.agent", agent)
|
||||
})
|
||||
|
||||
t.Run("all phases default names", func(t *testing.T) {
|
||||
resources := &types.Resources{}
|
||||
assert.Equal(t, "__yao.inspiration", resources.GetPhaseAgent(types.PhaseInspiration))
|
||||
assert.Equal(t, "__yao.goals", resources.GetPhaseAgent(types.PhaseGoals))
|
||||
assert.Equal(t, "__yao.tasks", resources.GetPhaseAgent(types.PhaseTasks))
|
||||
assert.Equal(t, "__yao.run", resources.GetPhaseAgent(types.PhaseRun))
|
||||
assert.Equal(t, "__yao.delivery", resources.GetPhaseAgent(types.PhaseDelivery))
|
||||
assert.Equal(t, "__yao.learning", resources.GetPhaseAgent(types.PhaseLearning))
|
||||
})
|
||||
}
|
||||
43
agent/robot/types/context.go
Normal file
43
agent/robot/types/context.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// Context - robot execution context (lightweight)
|
||||
type Context struct {
|
||||
context.Context // embed standard context
|
||||
Auth *types.AuthorizedInfo `json:"auth,omitempty"` // reuse oauth AuthorizedInfo
|
||||
MemberID string `json:"member_id,omitempty"` // current robot member ID
|
||||
RequestID string `json:"request_id,omitempty"` // request trace ID
|
||||
Locale string `json:"locale,omitempty"` // locale (e.g., "en-US")
|
||||
}
|
||||
|
||||
// NewContext creates a new robot context
|
||||
func NewContext(parent context.Context, auth *types.AuthorizedInfo) *Context {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
return &Context{
|
||||
Context: parent,
|
||||
Auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
// UserID returns user ID from auth
|
||||
func (c *Context) UserID() string {
|
||||
if c.Auth == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Auth.UserID
|
||||
}
|
||||
|
||||
// TeamID returns team ID from auth
|
||||
func (c *Context) TeamID() string {
|
||||
if c.Auth == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Auth.TeamID
|
||||
}
|
||||
191
agent/robot/types/enums.go
Normal file
191
agent/robot/types/enums.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package types
|
||||
|
||||
// Phase - execution phase
|
||||
type Phase string
|
||||
|
||||
// Phase constants define the execution phases for robot agent
|
||||
const (
|
||||
PhaseInspiration Phase = "inspiration" // P0: Clock only
|
||||
PhaseGoals Phase = "goals" // P1
|
||||
PhaseTasks Phase = "tasks" // P2
|
||||
PhaseRun Phase = "run" // P3
|
||||
PhaseDelivery Phase = "delivery" // P4
|
||||
PhaseLearning Phase = "learning" // P5
|
||||
)
|
||||
|
||||
// AllPhases for iteration
|
||||
var AllPhases = []Phase{
|
||||
PhaseInspiration, PhaseGoals, PhaseTasks,
|
||||
PhaseRun, PhaseDelivery, PhaseLearning,
|
||||
}
|
||||
|
||||
// ClockMode - clock trigger mode
|
||||
type ClockMode string
|
||||
|
||||
// ClockMode constants define the clock trigger modes
|
||||
const (
|
||||
ClockTimes ClockMode = "times" // run at specific times
|
||||
ClockInterval ClockMode = "interval" // run every X duration
|
||||
ClockDaemon ClockMode = "daemon" // run continuously
|
||||
)
|
||||
|
||||
// TriggerType - trigger source
|
||||
type TriggerType string
|
||||
|
||||
// TriggerType constants define the trigger sources
|
||||
const (
|
||||
TriggerClock TriggerType = "clock"
|
||||
TriggerHuman TriggerType = "human"
|
||||
TriggerEvent TriggerType = "event"
|
||||
)
|
||||
|
||||
// ExecStatus - execution status
|
||||
type ExecStatus string
|
||||
|
||||
// ExecStatus constants define the execution status values
|
||||
const (
|
||||
ExecPending ExecStatus = "pending"
|
||||
ExecRunning ExecStatus = "running"
|
||||
ExecCompleted ExecStatus = "completed"
|
||||
ExecFailed ExecStatus = "failed"
|
||||
ExecCancelled ExecStatus = "cancelled"
|
||||
)
|
||||
|
||||
// RobotStatus - matches __yao.member.robot_status
|
||||
type RobotStatus string
|
||||
|
||||
// RobotStatus constants define the robot status values
|
||||
const (
|
||||
RobotIdle RobotStatus = "idle"
|
||||
RobotWorking RobotStatus = "working"
|
||||
RobotPaused RobotStatus = "paused"
|
||||
RobotError RobotStatus = "error"
|
||||
RobotMaintenance RobotStatus = "maintenance"
|
||||
)
|
||||
|
||||
// InterventionAction - human intervention action
|
||||
// Format: category.action (e.g., "task.add", "goal.adjust")
|
||||
type InterventionAction string
|
||||
|
||||
// InterventionAction constants define the human intervention actions
|
||||
const (
|
||||
// ActionTaskAdd adds a new task
|
||||
ActionTaskAdd InterventionAction = "task.add"
|
||||
// ActionTaskCancel cancels a task
|
||||
ActionTaskCancel InterventionAction = "task.cancel"
|
||||
// ActionTaskUpdate updates task details
|
||||
ActionTaskUpdate InterventionAction = "task.update"
|
||||
|
||||
// ActionGoalAdjust modifies current goal
|
||||
ActionGoalAdjust InterventionAction = "goal.adjust"
|
||||
// ActionGoalAdd adds a new goal
|
||||
ActionGoalAdd InterventionAction = "goal.add"
|
||||
// ActionGoalComplete marks goal as complete
|
||||
ActionGoalComplete InterventionAction = "goal.complete"
|
||||
// ActionGoalCancel cancels a goal
|
||||
ActionGoalCancel InterventionAction = "goal.cancel"
|
||||
|
||||
// ActionPlanAdd adds to plan queue
|
||||
ActionPlanAdd InterventionAction = "plan.add"
|
||||
// ActionPlanRemove removes from plan queue
|
||||
ActionPlanRemove InterventionAction = "plan.remove"
|
||||
// ActionPlanUpdate updates planned item
|
||||
ActionPlanUpdate InterventionAction = "plan.update"
|
||||
|
||||
// ActionInstruct is a direct instruction to robot
|
||||
ActionInstruct InterventionAction = "instruct"
|
||||
)
|
||||
|
||||
// Priority - task/goal priority
|
||||
type Priority string
|
||||
|
||||
// Priority constants define the priority levels
|
||||
const (
|
||||
PriorityHigh Priority = "high"
|
||||
PriorityNormal Priority = "normal"
|
||||
PriorityLow Priority = "low"
|
||||
)
|
||||
|
||||
// DeliveryType - output delivery type
|
||||
type DeliveryType string
|
||||
|
||||
// DeliveryType constants define the output delivery types
|
||||
const (
|
||||
DeliveryEmail DeliveryType = "email"
|
||||
DeliveryFile DeliveryType = "file"
|
||||
DeliveryWebhook DeliveryType = "webhook"
|
||||
DeliveryNotify DeliveryType = "notify"
|
||||
)
|
||||
|
||||
// DedupResult - deduplication result
|
||||
type DedupResult string
|
||||
|
||||
// DedupResult constants define the deduplication results
|
||||
const (
|
||||
DedupSkip DedupResult = "skip" // skip execution
|
||||
DedupMerge DedupResult = "merge" // merge with existing
|
||||
DedupProceed DedupResult = "proceed" // proceed normally
|
||||
)
|
||||
|
||||
// EventSource - event trigger source
|
||||
type EventSource string
|
||||
|
||||
// EventSource constants define the event trigger sources
|
||||
const (
|
||||
EventWebhook EventSource = "webhook" // HTTP webhook
|
||||
EventDatabase EventSource = "database" // DB change trigger
|
||||
)
|
||||
|
||||
// LearningType - learning entry type
|
||||
type LearningType string
|
||||
|
||||
// LearningType constants define the learning entry types
|
||||
const (
|
||||
LearnExecution LearningType = "execution" // execution record
|
||||
LearnFeedback LearningType = "feedback" // error/fix feedback
|
||||
LearnInsight LearningType = "insight" // pattern/tip insight
|
||||
)
|
||||
|
||||
// TaskSource - how task was created
|
||||
type TaskSource string
|
||||
|
||||
// TaskSource constants define how a task was created
|
||||
const (
|
||||
TaskSourceAuto TaskSource = "auto" // generated by P2 (task planning)
|
||||
TaskSourceHuman TaskSource = "human" // added via human intervention
|
||||
TaskSourceEvent TaskSource = "event" // added via event trigger
|
||||
)
|
||||
|
||||
// ExecutorType - task executor type
|
||||
type ExecutorType string
|
||||
|
||||
// ExecutorType constants define the task executor types
|
||||
const (
|
||||
ExecutorAssistant ExecutorType = "assistant"
|
||||
ExecutorMCP ExecutorType = "mcp"
|
||||
ExecutorProcess ExecutorType = "process"
|
||||
)
|
||||
|
||||
// TaskStatus - task execution status
|
||||
type TaskStatus string
|
||||
|
||||
// TaskStatus constants define the task execution status values
|
||||
const (
|
||||
TaskPending TaskStatus = "pending"
|
||||
TaskRunning TaskStatus = "running"
|
||||
TaskCompleted TaskStatus = "completed"
|
||||
TaskFailed TaskStatus = "failed"
|
||||
TaskSkipped TaskStatus = "skipped"
|
||||
TaskCancelled TaskStatus = "cancelled"
|
||||
)
|
||||
|
||||
// InsertPosition - where to insert task in queue
|
||||
type InsertPosition string
|
||||
|
||||
// InsertPosition constants define where to insert task in queue
|
||||
const (
|
||||
InsertFirst InsertPosition = "first" // insert at beginning (highest priority)
|
||||
InsertLast InsertPosition = "last" // append at end (default)
|
||||
InsertNext InsertPosition = "next" // insert after current task
|
||||
InsertAt InsertPosition = "at" // insert at specific index (use AtIndex)
|
||||
)
|
||||
134
agent/robot/types/enums_test.go
Normal file
134
agent/robot/types/enums_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
func TestPhaseEnum(t *testing.T) {
|
||||
assert.Equal(t, types.Phase("inspiration"), types.PhaseInspiration)
|
||||
assert.Equal(t, types.Phase("goals"), types.PhaseGoals)
|
||||
assert.Equal(t, types.Phase("tasks"), types.PhaseTasks)
|
||||
assert.Equal(t, types.Phase("run"), types.PhaseRun)
|
||||
assert.Equal(t, types.Phase("delivery"), types.PhaseDelivery)
|
||||
assert.Equal(t, types.Phase("learning"), types.PhaseLearning)
|
||||
}
|
||||
|
||||
func TestAllPhases(t *testing.T) {
|
||||
assert.Len(t, types.AllPhases, 6)
|
||||
assert.Equal(t, types.PhaseInspiration, types.AllPhases[0])
|
||||
assert.Equal(t, types.PhaseGoals, types.AllPhases[1])
|
||||
assert.Equal(t, types.PhaseTasks, types.AllPhases[2])
|
||||
assert.Equal(t, types.PhaseRun, types.AllPhases[3])
|
||||
assert.Equal(t, types.PhaseDelivery, types.AllPhases[4])
|
||||
assert.Equal(t, types.PhaseLearning, types.AllPhases[5])
|
||||
}
|
||||
|
||||
func TestClockModeEnum(t *testing.T) {
|
||||
assert.Equal(t, types.ClockMode("times"), types.ClockTimes)
|
||||
assert.Equal(t, types.ClockMode("interval"), types.ClockInterval)
|
||||
assert.Equal(t, types.ClockMode("daemon"), types.ClockDaemon)
|
||||
}
|
||||
|
||||
func TestTriggerTypeEnum(t *testing.T) {
|
||||
assert.Equal(t, types.TriggerType("clock"), types.TriggerClock)
|
||||
assert.Equal(t, types.TriggerType("human"), types.TriggerHuman)
|
||||
assert.Equal(t, types.TriggerType("event"), types.TriggerEvent)
|
||||
}
|
||||
|
||||
func TestExecStatusEnum(t *testing.T) {
|
||||
assert.Equal(t, types.ExecStatus("pending"), types.ExecPending)
|
||||
assert.Equal(t, types.ExecStatus("running"), types.ExecRunning)
|
||||
assert.Equal(t, types.ExecStatus("completed"), types.ExecCompleted)
|
||||
assert.Equal(t, types.ExecStatus("failed"), types.ExecFailed)
|
||||
assert.Equal(t, types.ExecStatus("cancelled"), types.ExecCancelled)
|
||||
}
|
||||
|
||||
func TestRobotStatusEnum(t *testing.T) {
|
||||
assert.Equal(t, types.RobotStatus("idle"), types.RobotIdle)
|
||||
assert.Equal(t, types.RobotStatus("working"), types.RobotWorking)
|
||||
assert.Equal(t, types.RobotStatus("paused"), types.RobotPaused)
|
||||
assert.Equal(t, types.RobotStatus("error"), types.RobotError)
|
||||
assert.Equal(t, types.RobotStatus("maintenance"), types.RobotMaintenance)
|
||||
}
|
||||
|
||||
func TestInterventionActionEnum(t *testing.T) {
|
||||
// Task operations
|
||||
assert.Equal(t, types.InterventionAction("task.add"), types.ActionTaskAdd)
|
||||
assert.Equal(t, types.InterventionAction("task.cancel"), types.ActionTaskCancel)
|
||||
assert.Equal(t, types.InterventionAction("task.update"), types.ActionTaskUpdate)
|
||||
|
||||
// Goal operations
|
||||
assert.Equal(t, types.InterventionAction("goal.adjust"), types.ActionGoalAdjust)
|
||||
assert.Equal(t, types.InterventionAction("goal.add"), types.ActionGoalAdd)
|
||||
assert.Equal(t, types.InterventionAction("goal.complete"), types.ActionGoalComplete)
|
||||
assert.Equal(t, types.InterventionAction("goal.cancel"), types.ActionGoalCancel)
|
||||
|
||||
// Plan operations
|
||||
assert.Equal(t, types.InterventionAction("plan.add"), types.ActionPlanAdd)
|
||||
assert.Equal(t, types.InterventionAction("plan.remove"), types.ActionPlanRemove)
|
||||
assert.Equal(t, types.InterventionAction("plan.update"), types.ActionPlanUpdate)
|
||||
|
||||
// Instruction
|
||||
assert.Equal(t, types.InterventionAction("instruct"), types.ActionInstruct)
|
||||
}
|
||||
|
||||
func TestPriorityEnum(t *testing.T) {
|
||||
assert.Equal(t, types.Priority("high"), types.PriorityHigh)
|
||||
assert.Equal(t, types.Priority("normal"), types.PriorityNormal)
|
||||
assert.Equal(t, types.Priority("low"), types.PriorityLow)
|
||||
}
|
||||
|
||||
func TestDeliveryTypeEnum(t *testing.T) {
|
||||
assert.Equal(t, types.DeliveryType("email"), types.DeliveryEmail)
|
||||
assert.Equal(t, types.DeliveryType("file"), types.DeliveryFile)
|
||||
assert.Equal(t, types.DeliveryType("webhook"), types.DeliveryWebhook)
|
||||
assert.Equal(t, types.DeliveryType("notify"), types.DeliveryNotify)
|
||||
}
|
||||
|
||||
func TestDedupResultEnum(t *testing.T) {
|
||||
assert.Equal(t, types.DedupResult("skip"), types.DedupSkip)
|
||||
assert.Equal(t, types.DedupResult("merge"), types.DedupMerge)
|
||||
assert.Equal(t, types.DedupResult("proceed"), types.DedupProceed)
|
||||
}
|
||||
|
||||
func TestEventSourceEnum(t *testing.T) {
|
||||
assert.Equal(t, types.EventSource("webhook"), types.EventWebhook)
|
||||
assert.Equal(t, types.EventSource("database"), types.EventDatabase)
|
||||
}
|
||||
|
||||
func TestLearningTypeEnum(t *testing.T) {
|
||||
assert.Equal(t, types.LearningType("execution"), types.LearnExecution)
|
||||
assert.Equal(t, types.LearningType("feedback"), types.LearnFeedback)
|
||||
assert.Equal(t, types.LearningType("insight"), types.LearnInsight)
|
||||
}
|
||||
|
||||
func TestTaskSourceEnum(t *testing.T) {
|
||||
assert.Equal(t, types.TaskSource("auto"), types.TaskSourceAuto)
|
||||
assert.Equal(t, types.TaskSource("human"), types.TaskSourceHuman)
|
||||
assert.Equal(t, types.TaskSource("event"), types.TaskSourceEvent)
|
||||
}
|
||||
|
||||
func TestExecutorTypeEnum(t *testing.T) {
|
||||
assert.Equal(t, types.ExecutorType("assistant"), types.ExecutorAssistant)
|
||||
assert.Equal(t, types.ExecutorType("mcp"), types.ExecutorMCP)
|
||||
assert.Equal(t, types.ExecutorType("process"), types.ExecutorProcess)
|
||||
}
|
||||
|
||||
func TestTaskStatusEnum(t *testing.T) {
|
||||
assert.Equal(t, types.TaskStatus("pending"), types.TaskPending)
|
||||
assert.Equal(t, types.TaskStatus("running"), types.TaskRunning)
|
||||
assert.Equal(t, types.TaskStatus("completed"), types.TaskCompleted)
|
||||
assert.Equal(t, types.TaskStatus("failed"), types.TaskFailed)
|
||||
assert.Equal(t, types.TaskStatus("skipped"), types.TaskSkipped)
|
||||
assert.Equal(t, types.TaskStatus("cancelled"), types.TaskCancelled)
|
||||
}
|
||||
|
||||
func TestInsertPositionEnum(t *testing.T) {
|
||||
assert.Equal(t, types.InsertPosition("first"), types.InsertFirst)
|
||||
assert.Equal(t, types.InsertPosition("last"), types.InsertLast)
|
||||
assert.Equal(t, types.InsertPosition("next"), types.InsertNext)
|
||||
assert.Equal(t, types.InsertPosition("at"), types.InsertAt)
|
||||
}
|
||||
48
agent/robot/types/errors.go
Normal file
48
agent/robot/types/errors.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package types
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrMissingIdentity indicates identity.role is required
|
||||
var ErrMissingIdentity = errors.New("identity.role is required")
|
||||
|
||||
// ErrClockTimesEmpty indicates clock.times is required for times mode
|
||||
var ErrClockTimesEmpty = errors.New("clock.times is required for times mode")
|
||||
|
||||
// ErrClockIntervalEmpty indicates clock.every is required for interval mode
|
||||
var ErrClockIntervalEmpty = errors.New("clock.every is required for interval mode")
|
||||
|
||||
// ErrClockModeInvalid indicates clock.mode must be times, interval, or daemon
|
||||
var ErrClockModeInvalid = errors.New("clock.mode must be times, interval, or daemon")
|
||||
|
||||
// ErrRobotNotFound indicates robot not found
|
||||
var ErrRobotNotFound = errors.New("robot not found")
|
||||
|
||||
// ErrRobotPaused indicates robot is paused
|
||||
var ErrRobotPaused = errors.New("robot is paused")
|
||||
|
||||
// ErrRobotBusy indicates robot has reached max concurrent executions
|
||||
var ErrRobotBusy = errors.New("robot has reached max concurrent executions")
|
||||
|
||||
// ErrQuotaExceeded indicates robot quota was exceeded (atomic check failed)
|
||||
var ErrQuotaExceeded = errors.New("robot quota exceeded")
|
||||
|
||||
// ErrTriggerDisabled indicates trigger type is disabled for this robot
|
||||
var ErrTriggerDisabled = errors.New("trigger type is disabled for this robot")
|
||||
|
||||
// ErrExecutionCancelled indicates execution was cancelled
|
||||
var ErrExecutionCancelled = errors.New("execution was cancelled")
|
||||
|
||||
// ErrExecutionTimeout indicates execution timed out
|
||||
var ErrExecutionTimeout = errors.New("execution timed out")
|
||||
|
||||
// ErrPhaseAgentNotFound indicates phase agent not found
|
||||
var ErrPhaseAgentNotFound = errors.New("phase agent not found")
|
||||
|
||||
// ErrGoalGenFailed indicates goal generation failed
|
||||
var ErrGoalGenFailed = errors.New("goal generation failed")
|
||||
|
||||
// ErrTaskPlanFailed indicates task planning failed
|
||||
var ErrTaskPlanFailed = errors.New("task planning failed")
|
||||
|
||||
// ErrDeliveryFailed indicates delivery failed
|
||||
var ErrDeliveryFailed = errors.New("delivery failed")
|
||||
22
agent/robot/types/inspiration.go
Normal file
22
agent/robot/types/inspiration.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package types
|
||||
|
||||
// InspirationReport - P0 output (simple markdown for LLM)
|
||||
type InspirationReport struct {
|
||||
Clock *ClockContext `json:"clock"` // time context
|
||||
Content string `json:"content"` // markdown text for LLM
|
||||
}
|
||||
|
||||
// Content is markdown like:
|
||||
// ## Summary
|
||||
// ...
|
||||
// ## Highlights
|
||||
// - [High] Sales up 50%
|
||||
// - [Medium] New lead from BigCorp
|
||||
// ## Opportunities
|
||||
// ...
|
||||
// ## Risks
|
||||
// ...
|
||||
// ## World News
|
||||
// ...
|
||||
// ## Pending
|
||||
// ...
|
||||
53
agent/robot/types/interfaces.go
Normal file
53
agent/robot/types/interfaces.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// ==================== Internal Interfaces ====================
|
||||
// These are internal implementation interfaces, not exposed via API.
|
||||
// External API is defined in api/api.go
|
||||
// All interfaces use *Context (not context.Context) for consistency.
|
||||
|
||||
// Manager - robot lifecycle and clock trigger management
|
||||
type Manager interface {
|
||||
Start() error
|
||||
Stop() error
|
||||
Tick(ctx *Context, now time.Time) error
|
||||
}
|
||||
|
||||
// Executor - executes robot phases
|
||||
type Executor interface {
|
||||
Execute(ctx *Context, robot *Robot, trigger TriggerType, data interface{}) (*Execution, error)
|
||||
}
|
||||
|
||||
// Pool - worker pool for concurrent execution
|
||||
type Pool interface {
|
||||
Start() error
|
||||
Stop() error
|
||||
Submit(ctx *Context, robot *Robot, trigger TriggerType, data interface{}) (string, error)
|
||||
Running() int
|
||||
Queued() int
|
||||
}
|
||||
|
||||
// Cache - in-memory robot cache
|
||||
type Cache interface {
|
||||
Load(ctx *Context) error
|
||||
Get(memberID string) *Robot
|
||||
List(teamID string) []*Robot
|
||||
Refresh(ctx *Context, memberID string) error
|
||||
Add(robot *Robot)
|
||||
Remove(memberID string)
|
||||
}
|
||||
|
||||
// Dedup - deduplication check
|
||||
type Dedup interface {
|
||||
Check(ctx *Context, memberID string, trigger TriggerType) (DedupResult, error)
|
||||
Mark(memberID string, trigger TriggerType, window time.Duration)
|
||||
}
|
||||
|
||||
// Store - data storage operations (KB, DB)
|
||||
type Store interface {
|
||||
SaveLearning(ctx *Context, memberID string, entries []LearningEntry) error
|
||||
GetHistory(ctx *Context, memberID string, limit int) ([]LearningEntry, error)
|
||||
SearchKB(ctx *Context, collections []string, query string) ([]interface{}, error)
|
||||
QueryDB(ctx *Context, models []string, query interface{}) ([]interface{}, error)
|
||||
}
|
||||
44
agent/robot/types/request.go
Normal file
44
agent/robot/types/request.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// InterveneRequest - human intervention request
|
||||
type InterveneRequest struct {
|
||||
TeamID string `json:"team_id"`
|
||||
MemberID string `json:"member_id"`
|
||||
Action InterventionAction `json:"action"`
|
||||
Messages []agentcontext.Message `json:"messages"` // user input (text, images, files)
|
||||
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan
|
||||
}
|
||||
|
||||
// EventRequest - event trigger request
|
||||
type EventRequest struct {
|
||||
MemberID string `json:"member_id"`
|
||||
Source string `json:"source"` // webhook path or table name
|
||||
EventType string `json:"event_type"` // lead.created, etc.
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// ExecutionResult - trigger result
|
||||
type ExecutionResult struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
Status ExecStatus `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// RobotState - robot status query result
|
||||
type RobotState struct {
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status RobotStatus `json:"status"`
|
||||
Running int `json:"running"` // current running execution count
|
||||
MaxRunning int `json:"max_running"` // max concurrent allowed
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"` // list of running execution IDs
|
||||
}
|
||||
307
agent/robot/types/robot.go
Normal file
307
agent/robot/types/robot.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// 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)
|
||||
type Robot struct {
|
||||
// From __yao.member
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Status RobotStatus `json:"robot_status"`
|
||||
AutonomousMode bool `json:"autonomous_mode"`
|
||||
|
||||
// Parsed config (from robot_config JSON field)
|
||||
Config *Config `json:"-"`
|
||||
|
||||
// Runtime state
|
||||
LastRun time.Time `json:"-"` // last execution start time
|
||||
NextRun time.Time `json:"-"` // next scheduled execution (for clock trigger)
|
||||
|
||||
// Concurrency control
|
||||
// Each Robot can run multiple Executions concurrently (up to Quota.Max)
|
||||
executions map[string]*Execution // execID -> Execution
|
||||
execMu sync.RWMutex
|
||||
}
|
||||
|
||||
// CanRun checks if robot can accept new execution
|
||||
// Note: This is a read-only check. For atomic check-and-acquire, use TryAcquireSlot()
|
||||
func (r *Robot) CanRun() bool {
|
||||
r.execMu.RLock()
|
||||
defer r.execMu.RUnlock()
|
||||
if r.Config == nil {
|
||||
return len(r.executions) < 2 // default max
|
||||
}
|
||||
return len(r.executions) < r.Config.Quota.GetMax()
|
||||
}
|
||||
|
||||
// TryAcquireSlot atomically checks if robot can run and reserves a slot
|
||||
// Returns true if slot was acquired, false if quota is full
|
||||
// This prevents race conditions between CanRun() check and AddExecution()
|
||||
func (r *Robot) TryAcquireSlot(exec *Execution) bool {
|
||||
r.execMu.Lock()
|
||||
defer r.execMu.Unlock()
|
||||
|
||||
// Get max quota
|
||||
maxQuota := 2 // default
|
||||
if r.Config != nil {
|
||||
maxQuota = r.Config.Quota.GetMax()
|
||||
}
|
||||
|
||||
// Check if we can add
|
||||
if len(r.executions) >= maxQuota {
|
||||
return false // quota full
|
||||
}
|
||||
|
||||
// Reserve slot by adding execution
|
||||
if r.executions == nil {
|
||||
r.executions = make(map[string]*Execution)
|
||||
}
|
||||
r.executions[exec.ID] = exec
|
||||
return true
|
||||
}
|
||||
|
||||
// RunningCount returns current running execution count
|
||||
func (r *Robot) RunningCount() int {
|
||||
r.execMu.RLock()
|
||||
defer r.execMu.RUnlock()
|
||||
return len(r.executions)
|
||||
}
|
||||
|
||||
// AddExecution adds an execution to tracking
|
||||
// Note: Prefer TryAcquireSlot() for atomic check-and-add
|
||||
func (r *Robot) AddExecution(exec *Execution) {
|
||||
r.execMu.Lock()
|
||||
defer r.execMu.Unlock()
|
||||
if r.executions == nil {
|
||||
r.executions = make(map[string]*Execution)
|
||||
}
|
||||
r.executions[exec.ID] = exec
|
||||
}
|
||||
|
||||
// RemoveExecution removes an execution from tracking
|
||||
func (r *Robot) RemoveExecution(execID string) {
|
||||
r.execMu.Lock()
|
||||
defer r.execMu.Unlock()
|
||||
delete(r.executions, execID)
|
||||
}
|
||||
|
||||
// GetExecution returns an execution by ID
|
||||
func (r *Robot) GetExecution(execID string) *Execution {
|
||||
r.execMu.RLock()
|
||||
defer r.execMu.RUnlock()
|
||||
return r.executions[execID]
|
||||
}
|
||||
|
||||
// GetExecutions returns all running executions
|
||||
func (r *Robot) GetExecutions() []*Execution {
|
||||
r.execMu.RLock()
|
||||
defer r.execMu.RUnlock()
|
||||
execs := make([]*Execution, 0, len(r.executions))
|
||||
for _, exec := range r.executions {
|
||||
execs = append(execs, exec)
|
||||
}
|
||||
return execs
|
||||
}
|
||||
|
||||
// Execution - single execution instance
|
||||
// Each trigger creates a new Execution, mapped to a job.Job for monitoring
|
||||
// Relationship: 1 Execution = 1 job.Job
|
||||
type Execution struct {
|
||||
ID string `json:"id"` // unique execution ID
|
||||
MemberID string `json:"member_id"` // robot member ID
|
||||
TeamID string `json:"team_id"`
|
||||
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
Status ExecStatus `json:"status"`
|
||||
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
|
||||
|
||||
// Phase outputs
|
||||
Inspiration *InspirationReport `json:"inspiration,omitempty"` // P0: markdown
|
||||
Goals *Goals `json:"goals,omitempty"` // P1: markdown
|
||||
Tasks []Task `json:"tasks,omitempty"` // P2: structured tasks
|
||||
Current *CurrentState `json:"current,omitempty"` // current executing state
|
||||
Results []TaskResult `json:"results,omitempty"` // P3: task results
|
||||
Delivery *DeliveryResult `json:"delivery,omitempty"`
|
||||
Learning []LearningEntry `json:"learning,omitempty"`
|
||||
|
||||
// Runtime (internal, not serialized)
|
||||
ctx context.Context `json:"-"`
|
||||
cancel context.CancelFunc `json:"-"`
|
||||
robot *Robot `json:"-"`
|
||||
}
|
||||
|
||||
// TriggerInput - stored trigger input for traceability
|
||||
type TriggerInput struct {
|
||||
// For human intervention
|
||||
Action InterventionAction `json:"action,omitempty"` // task.add, goal.adjust, etc.
|
||||
Messages []agentcontext.Message `json:"messages,omitempty"` // user's input (text, images, files)
|
||||
UserID string `json:"user_id,omitempty"` // who triggered
|
||||
|
||||
// For event trigger
|
||||
Source EventSource `json:"source,omitempty"` // webhook | database
|
||||
EventType string `json:"event_type,omitempty"` // lead.created, etc.
|
||||
Data map[string]interface{} `json:"data,omitempty"` // event payload
|
||||
|
||||
// For clock trigger
|
||||
Clock *ClockContext `json:"clock,omitempty"` // time context when triggered
|
||||
}
|
||||
|
||||
// CurrentState - current executing goal and task
|
||||
type CurrentState struct {
|
||||
Task *Task `json:"task,omitempty"` // current task being executed
|
||||
TaskIndex int `json:"task_index"` // index in Tasks slice
|
||||
Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks")
|
||||
}
|
||||
|
||||
// Goals - P1 output (markdown for LLM)
|
||||
// P1 Agent reads InspirationReport and generates goals as markdown
|
||||
// Example:
|
||||
// ## Goals
|
||||
// 1. [High] Analyze sales data and identify trends
|
||||
// - Reason: Sales up 50%, need to understand why
|
||||
//
|
||||
// 2. [Normal] Prepare weekly report for manager
|
||||
// - Reason: Friday 5pm, weekly report due
|
||||
//
|
||||
// 3. [Low] Update CRM with new leads
|
||||
// - Reason: 3 pending leads from yesterday
|
||||
type Goals struct {
|
||||
Content string `json:"content"` // markdown text
|
||||
}
|
||||
|
||||
// Task - planned task (structured, for execution)
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Messages []agentcontext.Message `json:"messages"` // original input (text, images, files)
|
||||
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource `json:"source"` // auto | human | event
|
||||
|
||||
// Executor
|
||||
ExecutorType ExecutorType `json:"executor_type"`
|
||||
ExecutorID string `json:"executor_id"`
|
||||
Args []any `json:"args,omitempty"`
|
||||
|
||||
// Runtime
|
||||
Status TaskStatus `json:"status"`
|
||||
Order int `json:"order"` // execution order (0-based)
|
||||
StartTime *time.Time `json:"start_time,omitempty"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
}
|
||||
|
||||
// TaskResult - task execution result
|
||||
type TaskResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Success bool `json:"success"`
|
||||
Output interface{} `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
Validated bool `json:"validated"`
|
||||
}
|
||||
|
||||
// DeliveryResult - delivery output
|
||||
type DeliveryResult struct {
|
||||
Type DeliveryType `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LearningEntry - knowledge to save
|
||||
type LearningEntry struct {
|
||||
Type LearningType `json:"type"` // execution | feedback | insight
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// NewRobotFromMap creates a Robot from a map (typically from DB record)
|
||||
func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
|
||||
memberID := getString(m, "member_id")
|
||||
teamID := getString(m, "team_id")
|
||||
|
||||
// Validate required fields
|
||||
if memberID == "" || teamID == "" {
|
||||
return nil, fmt.Errorf("missing required fields: member_id or team_id")
|
||||
}
|
||||
|
||||
robot := &Robot{
|
||||
MemberID: memberID,
|
||||
TeamID: teamID,
|
||||
DisplayName: getString(m, "display_name"),
|
||||
SystemPrompt: getString(m, "system_prompt"),
|
||||
AutonomousMode: getBool(m, "autonomous_mode"),
|
||||
}
|
||||
|
||||
// Parse robot_status
|
||||
if status := getString(m, "robot_status"); status != "" {
|
||||
robot.Status = RobotStatus(status)
|
||||
} else {
|
||||
robot.Status = RobotIdle
|
||||
}
|
||||
|
||||
// Parse robot_config JSON
|
||||
if configData, ok := m["robot_config"]; ok && configData != nil {
|
||||
config, err := ParseConfig(configData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse robot_config: %w", err)
|
||||
}
|
||||
robot.Config = config
|
||||
}
|
||||
|
||||
return robot, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", 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
|
||||
}
|
||||
466
agent/robot/types/robot_test.go
Normal file
466
agent/robot/types/robot_test.go
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
func TestRobotCanRun(t *testing.T) {
|
||||
t.Run("can run when under quota", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 2},
|
||||
},
|
||||
}
|
||||
assert.True(t, robot.CanRun())
|
||||
})
|
||||
|
||||
t.Run("can run with nil config (uses default quota)", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: nil, // nil config should not panic
|
||||
}
|
||||
// Should not panic and use default max (2)
|
||||
assert.True(t, robot.CanRun())
|
||||
})
|
||||
|
||||
t.Run("can run with nil quota (uses default)", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: nil, // nil quota should use default
|
||||
},
|
||||
}
|
||||
assert.True(t, robot.CanRun())
|
||||
})
|
||||
|
||||
t.Run("cannot run when at quota", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 2},
|
||||
},
|
||||
}
|
||||
|
||||
// Add 2 executions to reach quota
|
||||
exec1 := &types.Execution{ID: "exec1"}
|
||||
exec2 := &types.Execution{ID: "exec2"}
|
||||
robot.AddExecution(exec1)
|
||||
robot.AddExecution(exec2)
|
||||
|
||||
assert.False(t, robot.CanRun())
|
||||
})
|
||||
|
||||
t.Run("can run after removing execution", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 2},
|
||||
},
|
||||
}
|
||||
|
||||
exec1 := &types.Execution{ID: "exec1"}
|
||||
exec2 := &types.Execution{ID: "exec2"}
|
||||
robot.AddExecution(exec1)
|
||||
robot.AddExecution(exec2)
|
||||
|
||||
assert.False(t, robot.CanRun())
|
||||
|
||||
robot.RemoveExecution("exec1")
|
||||
assert.True(t, robot.CanRun())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRobotRunningCount(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 5},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, robot.RunningCount())
|
||||
|
||||
exec1 := &types.Execution{ID: "exec1"}
|
||||
robot.AddExecution(exec1)
|
||||
assert.Equal(t, 1, robot.RunningCount())
|
||||
|
||||
exec2 := &types.Execution{ID: "exec2"}
|
||||
robot.AddExecution(exec2)
|
||||
assert.Equal(t, 2, robot.RunningCount())
|
||||
|
||||
robot.RemoveExecution("exec1")
|
||||
assert.Equal(t, 1, robot.RunningCount())
|
||||
|
||||
robot.RemoveExecution("exec2")
|
||||
assert.Equal(t, 0, robot.RunningCount())
|
||||
}
|
||||
|
||||
func TestRobotAddExecution(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 2},
|
||||
},
|
||||
}
|
||||
|
||||
exec := &types.Execution{
|
||||
ID: "exec1",
|
||||
MemberID: "member1",
|
||||
}
|
||||
|
||||
robot.AddExecution(exec)
|
||||
assert.Equal(t, 1, robot.RunningCount())
|
||||
|
||||
retrieved := robot.GetExecution("exec1")
|
||||
assert.NotNil(t, retrieved)
|
||||
assert.Equal(t, "exec1", retrieved.ID)
|
||||
assert.Equal(t, "member1", retrieved.MemberID)
|
||||
}
|
||||
|
||||
func TestRobotRemoveExecution(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 2},
|
||||
},
|
||||
}
|
||||
|
||||
exec := &types.Execution{ID: "exec1"}
|
||||
robot.AddExecution(exec)
|
||||
assert.Equal(t, 1, robot.RunningCount())
|
||||
|
||||
robot.RemoveExecution("exec1")
|
||||
assert.Equal(t, 0, robot.RunningCount())
|
||||
|
||||
retrieved := robot.GetExecution("exec1")
|
||||
assert.Nil(t, retrieved)
|
||||
}
|
||||
|
||||
func TestRobotGetExecution(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 2},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("get existing execution", func(t *testing.T) {
|
||||
exec := &types.Execution{
|
||||
ID: "exec1",
|
||||
MemberID: "member1",
|
||||
}
|
||||
robot.AddExecution(exec)
|
||||
|
||||
retrieved := robot.GetExecution("exec1")
|
||||
assert.NotNil(t, retrieved)
|
||||
assert.Equal(t, "exec1", retrieved.ID)
|
||||
})
|
||||
|
||||
t.Run("get non-existing execution", func(t *testing.T) {
|
||||
retrieved := robot.GetExecution("non-existing")
|
||||
assert.Nil(t, retrieved)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRobotGetExecutions(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 5},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("empty executions", func(t *testing.T) {
|
||||
execs := robot.GetExecutions()
|
||||
assert.Empty(t, execs)
|
||||
})
|
||||
|
||||
t.Run("multiple executions", func(t *testing.T) {
|
||||
exec1 := &types.Execution{ID: "exec1"}
|
||||
exec2 := &types.Execution{ID: "exec2"}
|
||||
exec3 := &types.Execution{ID: "exec3"}
|
||||
|
||||
robot.AddExecution(exec1)
|
||||
robot.AddExecution(exec2)
|
||||
robot.AddExecution(exec3)
|
||||
|
||||
execs := robot.GetExecutions()
|
||||
assert.Len(t, execs, 3)
|
||||
|
||||
// Check all executions are present
|
||||
ids := make(map[string]bool)
|
||||
for _, exec := range execs {
|
||||
ids[exec.ID] = true
|
||||
}
|
||||
assert.True(t, ids["exec1"])
|
||||
assert.True(t, ids["exec2"])
|
||||
assert.True(t, ids["exec3"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestRobotConcurrentAccess(t *testing.T) {
|
||||
// Test thread-safe execution management
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 10},
|
||||
},
|
||||
}
|
||||
|
||||
// Add executions concurrently
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(id int) {
|
||||
exec := &types.Execution{ID: string(rune('0' + id))}
|
||||
robot.AddExecution(exec)
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 5; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify count
|
||||
count := robot.RunningCount()
|
||||
assert.Equal(t, 5, count)
|
||||
|
||||
// Remove executions concurrently
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(id int) {
|
||||
robot.RemoveExecution(string(rune('0' + id)))
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 5; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify count
|
||||
count = robot.RunningCount()
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
func TestRobotTryAcquireSlot(t *testing.T) {
|
||||
t.Run("acquire slot when under quota", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 2},
|
||||
},
|
||||
}
|
||||
|
||||
exec := &types.Execution{ID: "exec1"}
|
||||
acquired := robot.TryAcquireSlot(exec)
|
||||
|
||||
assert.True(t, acquired)
|
||||
assert.Equal(t, 1, robot.RunningCount())
|
||||
assert.NotNil(t, robot.GetExecution("exec1"))
|
||||
})
|
||||
|
||||
t.Run("fail to acquire when at quota", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 2},
|
||||
},
|
||||
}
|
||||
|
||||
// Fill quota
|
||||
robot.TryAcquireSlot(&types.Execution{ID: "exec1"})
|
||||
robot.TryAcquireSlot(&types.Execution{ID: "exec2"})
|
||||
|
||||
// Try to acquire one more
|
||||
exec3 := &types.Execution{ID: "exec3"}
|
||||
acquired := robot.TryAcquireSlot(exec3)
|
||||
|
||||
assert.False(t, acquired)
|
||||
assert.Equal(t, 2, robot.RunningCount())
|
||||
assert.Nil(t, robot.GetExecution("exec3"))
|
||||
})
|
||||
|
||||
t.Run("acquire with nil config uses default quota", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
Config: nil, // default quota is 2
|
||||
}
|
||||
|
||||
exec1 := &types.Execution{ID: "exec1"}
|
||||
exec2 := &types.Execution{ID: "exec2"}
|
||||
exec3 := &types.Execution{ID: "exec3"}
|
||||
|
||||
assert.True(t, robot.TryAcquireSlot(exec1))
|
||||
assert.True(t, robot.TryAcquireSlot(exec2))
|
||||
assert.False(t, robot.TryAcquireSlot(exec3)) // should fail at default max=2
|
||||
})
|
||||
}
|
||||
|
||||
func TestRobotTryAcquireSlotConcurrent(t *testing.T) {
|
||||
// Test that TryAcquireSlot is atomic and prevents exceeding quota
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 5},
|
||||
},
|
||||
}
|
||||
|
||||
// Launch 20 goroutines trying to acquire slots
|
||||
successCount := make(chan bool, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
go func(id int) {
|
||||
exec := &types.Execution{ID: string(rune('A' + id))}
|
||||
success := robot.TryAcquireSlot(exec)
|
||||
successCount <- success
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Count successes
|
||||
acquired := 0
|
||||
for i := 0; i < 20; i++ {
|
||||
if <-successCount {
|
||||
acquired++
|
||||
}
|
||||
}
|
||||
|
||||
// Should have exactly 5 successful acquisitions (quota max)
|
||||
assert.Equal(t, 5, acquired, "Should acquire exactly quota max slots")
|
||||
assert.Equal(t, 5, robot.RunningCount(), "Running count should match quota max")
|
||||
}
|
||||
|
||||
func TestRobotTryAcquireSlotRaceCondition(t *testing.T) {
|
||||
// Stress test to verify no race condition in TryAcquireSlot
|
||||
for iteration := 0; iteration < 100; iteration++ {
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Quota: &types.Quota{Max: 3},
|
||||
},
|
||||
}
|
||||
|
||||
// Launch many goroutines simultaneously
|
||||
successCount := make(chan bool, 50)
|
||||
for i := 0; i < 50; i++ {
|
||||
go func(id int) {
|
||||
exec := &types.Execution{ID: string(rune('A'+id%26)) + string(rune('0'+id/26))}
|
||||
success := robot.TryAcquireSlot(exec)
|
||||
successCount <- success
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Count successes
|
||||
acquired := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
if <-successCount {
|
||||
acquired++
|
||||
}
|
||||
}
|
||||
|
||||
// Should never exceed quota
|
||||
assert.Equal(t, 3, acquired, "Iteration %d: Should acquire exactly quota max slots", iteration)
|
||||
assert.Equal(t, 3, robot.RunningCount(), "Iteration %d: Running count should match quota max", iteration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionStructure(t *testing.T) {
|
||||
t.Run("execution with all fields", func(t *testing.T) {
|
||||
exec := &types.Execution{
|
||||
ID: "exec1",
|
||||
MemberID: "member1",
|
||||
TeamID: "team1",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseGoals,
|
||||
JobID: "job1",
|
||||
}
|
||||
|
||||
assert.Equal(t, "exec1", exec.ID)
|
||||
assert.Equal(t, "member1", exec.MemberID)
|
||||
assert.Equal(t, "team1", exec.TeamID)
|
||||
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) {
|
||||
exec := &types.Execution{
|
||||
ID: "exec1",
|
||||
Input: &types.TriggerInput{
|
||||
Action: types.ActionTaskAdd,
|
||||
UserID: "user1",
|
||||
},
|
||||
}
|
||||
|
||||
assert.NotNil(t, exec.Input)
|
||||
assert.Equal(t, types.ActionTaskAdd, exec.Input.Action)
|
||||
assert.Equal(t, "user1", exec.Input.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskStructure(t *testing.T) {
|
||||
task := &types.Task{
|
||||
ID: "task1",
|
||||
GoalRef: "Goal 1",
|
||||
Source: types.TaskSourceAuto,
|
||||
ExecutorType: types.ExecutorAssistant,
|
||||
ExecutorID: "assistant1",
|
||||
Status: types.TaskPending,
|
||||
Order: 0,
|
||||
}
|
||||
|
||||
assert.Equal(t, "task1", task.ID)
|
||||
assert.Equal(t, "Goal 1", task.GoalRef)
|
||||
assert.Equal(t, types.TaskSourceAuto, task.Source)
|
||||
assert.Equal(t, types.ExecutorAssistant, task.ExecutorType)
|
||||
assert.Equal(t, "assistant1", task.ExecutorID)
|
||||
assert.Equal(t, types.TaskPending, task.Status)
|
||||
assert.Equal(t, 0, task.Order)
|
||||
}
|
||||
|
||||
func TestGoalsStructure(t *testing.T) {
|
||||
goals := &types.Goals{
|
||||
Content: "## Goals\n1. [High] Complete project\n2. [Normal] Review code",
|
||||
}
|
||||
|
||||
assert.Contains(t, goals.Content, "Goals")
|
||||
assert.Contains(t, goals.Content, "Complete project")
|
||||
}
|
||||
|
||||
func TestTaskResultStructure(t *testing.T) {
|
||||
result := &types.TaskResult{
|
||||
TaskID: "task1",
|
||||
Success: true,
|
||||
Output: "Task completed successfully",
|
||||
Duration: 1500,
|
||||
Validated: true,
|
||||
}
|
||||
|
||||
assert.Equal(t, "task1", result.TaskID)
|
||||
assert.True(t, result.Success)
|
||||
assert.Equal(t, "Task completed successfully", result.Output)
|
||||
assert.Equal(t, int64(1500), result.Duration)
|
||||
assert.True(t, result.Validated)
|
||||
}
|
||||
|
||||
func TestDeliveryResultStructure(t *testing.T) {
|
||||
delivery := &types.DeliveryResult{
|
||||
Type: types.DeliveryEmail,
|
||||
Success: true,
|
||||
Details: map[string]interface{}{
|
||||
"to": "user@example.com",
|
||||
"subject": "Daily Report",
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, types.DeliveryEmail, delivery.Type)
|
||||
assert.True(t, delivery.Success)
|
||||
assert.NotNil(t, delivery.Details)
|
||||
}
|
||||
|
||||
func TestLearningEntryStructure(t *testing.T) {
|
||||
entry := &types.LearningEntry{
|
||||
Type: types.LearnExecution,
|
||||
Content: "Successfully completed task using assistant",
|
||||
Tags: []string{"success", "assistant"},
|
||||
Meta: map[string]interface{}{
|
||||
"duration": 1500,
|
||||
"phase": "run",
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, types.LearnExecution, entry.Type)
|
||||
assert.Equal(t, "Successfully completed task using assistant", entry.Content)
|
||||
assert.Len(t, entry.Tags, 2)
|
||||
assert.NotNil(t, entry.Meta)
|
||||
}
|
||||
146
agent/robot/utils/convert.go
Normal file
146
agent/robot/utils/convert.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ToJSON converts any value to JSON string
|
||||
func ToJSON(v interface{}) (string, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// FromJSON parses JSON string to target
|
||||
func FromJSON(jsonStr string, target interface{}) error {
|
||||
return json.Unmarshal([]byte(jsonStr), target)
|
||||
}
|
||||
|
||||
// ToMap converts struct to map[string]interface{}
|
||||
func ToMap(v interface{}) (map[string]interface{}, error) {
|
||||
data, err := json.Marshal(v)
|
||||
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
|
||||
}
|
||||
|
||||
// FromMap converts map to struct
|
||||
func FromMap(m map[string]interface{}, target interface{}) error {
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// MergeMap merges source map into target map (shallow copy)
|
||||
func MergeMap(target, source map[string]interface{}) map[string]interface{} {
|
||||
if target == nil {
|
||||
target = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range source {
|
||||
target[k] = v
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// CloneMap creates a shallow copy of a map
|
||||
func CloneMap(m map[string]interface{}) map[string]interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
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
|
||||
}
|
||||
20
agent/robot/utils/id.go
Normal file
20
agent/robot/utils/id.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
|
||||
// NewID generates a new unique ID using nanoid
|
||||
func NewID() string {
|
||||
id, err := gonanoid.New()
|
||||
if err != nil {
|
||||
// Fallback to nanoid with default alphabet if error occurs
|
||||
return gonanoid.Must()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// NewIDWithPrefix generates a new ID with a prefix
|
||||
func NewIDWithPrefix(prefix string) string {
|
||||
return prefix + NewID()
|
||||
}
|
||||
114
agent/robot/utils/time.go
Normal file
114
agent/robot/utils/time.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParseTime parses a time string in HH:MM format
|
||||
func ParseTime(timeStr string) (hour, minute int, err error) {
|
||||
parts := strings.Split(timeStr, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid time format: %s (expected HH:MM)", timeStr)
|
||||
}
|
||||
|
||||
hour, err = strconv.Atoi(parts[0])
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return 0, 0, fmt.Errorf("invalid hour: %s", parts[0])
|
||||
}
|
||||
|
||||
minute, err = strconv.Atoi(parts[1])
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return 0, 0, fmt.Errorf("invalid minute: %s", parts[1])
|
||||
}
|
||||
|
||||
return hour, minute, nil
|
||||
}
|
||||
|
||||
// FormatTime formats hour and minute into HH:MM format
|
||||
func FormatTime(hour, minute int) string {
|
||||
return fmt.Sprintf("%02d:%02d", hour, minute)
|
||||
}
|
||||
|
||||
// LoadLocation loads a timezone location, returns Local if empty or invalid
|
||||
func LoadLocation(tz string) *time.Location {
|
||||
if tz == "" {
|
||||
return time.Local
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// ParseDuration parses a duration string with fallback default
|
||||
func ParseDuration(durStr string, defaultDur time.Duration) time.Duration {
|
||||
if durStr == "" {
|
||||
return defaultDur
|
||||
}
|
||||
d, err := time.ParseDuration(durStr)
|
||||
if err != nil {
|
||||
return defaultDur
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// IsTimeMatch checks if current time matches the specified time (HH:MM)
|
||||
func IsTimeMatch(now time.Time, timeStr string, loc *time.Location) bool {
|
||||
hour, minute, err := ParseTime(timeStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
nowInLoc := now.In(loc)
|
||||
return nowInLoc.Hour() == hour && nowInLoc.Minute() == minute
|
||||
}
|
||||
|
||||
// IsDayMatch checks if current day matches the specified day
|
||||
// days can be: "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", or "*" for any day
|
||||
func IsDayMatch(now time.Time, days []string) bool {
|
||||
if len(days) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
dayName := now.Weekday().String()[:3] // "Monday" -> "Mon"
|
||||
|
||||
for _, day := range days {
|
||||
if day == "*" || day == dayName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NextScheduledTime calculates the next time a scheduled time will occur
|
||||
func NextScheduledTime(now time.Time, timeStr string, days []string, loc *time.Location) (time.Time, error) {
|
||||
hour, minute, err := ParseTime(timeStr)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
nowInLoc := now.In(loc)
|
||||
|
||||
// Start from today at the specified time
|
||||
next := time.Date(nowInLoc.Year(), nowInLoc.Month(), nowInLoc.Day(), hour, minute, 0, 0, loc)
|
||||
|
||||
// If the time has passed today, start from tomorrow
|
||||
if next.Before(nowInLoc) || next.Equal(nowInLoc) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
// Find the next matching day (within 7 days)
|
||||
for i := 0; i < 7; i++ {
|
||||
if IsDayMatch(next, days) {
|
||||
return next, nil
|
||||
}
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
// If no matching day found (should not happen with valid days), return the calculated time
|
||||
return next, nil
|
||||
}
|
||||
299
agent/robot/utils/utils_test.go
Normal file
299
agent/robot/utils/utils_test.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package utils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/utils"
|
||||
)
|
||||
|
||||
// ID tests
|
||||
func TestNewID(t *testing.T) {
|
||||
id1 := utils.NewID()
|
||||
id2 := utils.NewID()
|
||||
|
||||
assert.NotEmpty(t, id1)
|
||||
assert.NotEmpty(t, id2)
|
||||
assert.NotEqual(t, id1, id2, "IDs should be unique")
|
||||
}
|
||||
|
||||
func TestNewIDWithPrefix(t *testing.T) {
|
||||
id := utils.NewIDWithPrefix("exec_")
|
||||
assert.NotEmpty(t, id)
|
||||
assert.Contains(t, id, "exec_")
|
||||
}
|
||||
|
||||
// Time tests
|
||||
func TestParseTime(t *testing.T) {
|
||||
t.Run("valid time", func(t *testing.T) {
|
||||
hour, minute, err := utils.ParseTime("14:30")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 14, hour)
|
||||
assert.Equal(t, 30, minute)
|
||||
})
|
||||
|
||||
t.Run("invalid format", func(t *testing.T) {
|
||||
_, _, err := utils.ParseTime("14-30")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid hour", func(t *testing.T) {
|
||||
_, _, err := utils.ParseTime("25:30")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid minute", func(t *testing.T) {
|
||||
_, _, err := utils.ParseTime("14:65")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatTime(t *testing.T) {
|
||||
result := utils.FormatTime(9, 5)
|
||||
assert.Equal(t, "09:05", result)
|
||||
|
||||
result = utils.FormatTime(14, 30)
|
||||
assert.Equal(t, "14:30", result)
|
||||
}
|
||||
|
||||
func TestLoadLocation(t *testing.T) {
|
||||
t.Run("valid timezone", func(t *testing.T) {
|
||||
loc := utils.LoadLocation("Asia/Shanghai")
|
||||
assert.NotNil(t, loc)
|
||||
assert.Equal(t, "Asia/Shanghai", loc.String())
|
||||
})
|
||||
|
||||
t.Run("empty timezone returns Local", func(t *testing.T) {
|
||||
loc := utils.LoadLocation("")
|
||||
assert.Equal(t, time.Local, loc)
|
||||
})
|
||||
|
||||
t.Run("invalid timezone returns Local", func(t *testing.T) {
|
||||
loc := utils.LoadLocation("Invalid/Timezone")
|
||||
assert.Equal(t, time.Local, loc)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
t.Run("valid duration", func(t *testing.T) {
|
||||
dur := utils.ParseDuration("30m", 10*time.Minute)
|
||||
assert.Equal(t, 30*time.Minute, dur)
|
||||
})
|
||||
|
||||
t.Run("empty returns default", func(t *testing.T) {
|
||||
dur := utils.ParseDuration("", 10*time.Minute)
|
||||
assert.Equal(t, 10*time.Minute, dur)
|
||||
})
|
||||
|
||||
t.Run("invalid returns default", func(t *testing.T) {
|
||||
dur := utils.ParseDuration("invalid", 10*time.Minute)
|
||||
assert.Equal(t, 10*time.Minute, dur)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsTimeMatch(t *testing.T) {
|
||||
loc := time.UTC
|
||||
testTime := time.Date(2024, 1, 15, 14, 30, 0, 0, loc)
|
||||
|
||||
t.Run("exact match", func(t *testing.T) {
|
||||
assert.True(t, utils.IsTimeMatch(testTime, "14:30", loc))
|
||||
})
|
||||
|
||||
t.Run("no match", func(t *testing.T) {
|
||||
assert.False(t, utils.IsTimeMatch(testTime, "14:31", loc))
|
||||
assert.False(t, utils.IsTimeMatch(testTime, "15:30", loc))
|
||||
})
|
||||
|
||||
t.Run("invalid time format", func(t *testing.T) {
|
||||
assert.False(t, utils.IsTimeMatch(testTime, "invalid", loc))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsDayMatch(t *testing.T) {
|
||||
monday := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) // Monday
|
||||
|
||||
t.Run("match specific day", func(t *testing.T) {
|
||||
assert.True(t, utils.IsDayMatch(monday, []string{"Mon"}))
|
||||
})
|
||||
|
||||
t.Run("match wildcard", func(t *testing.T) {
|
||||
assert.True(t, utils.IsDayMatch(monday, []string{"*"}))
|
||||
})
|
||||
|
||||
t.Run("no match", func(t *testing.T) {
|
||||
assert.False(t, utils.IsDayMatch(monday, []string{"Tue", "Wed"}))
|
||||
})
|
||||
|
||||
t.Run("empty days returns true", func(t *testing.T) {
|
||||
assert.True(t, utils.IsDayMatch(monday, []string{}))
|
||||
})
|
||||
}
|
||||
|
||||
// Convert tests
|
||||
func TestToJSON(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "test",
|
||||
"age": 30,
|
||||
}
|
||||
|
||||
json, err := utils.ToJSON(data)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, json, "test")
|
||||
assert.Contains(t, json, "30")
|
||||
}
|
||||
|
||||
func TestFromJSON(t *testing.T) {
|
||||
jsonStr := `{"name":"test","age":30}`
|
||||
|
||||
var result map[string]interface{}
|
||||
err := utils.FromJSON(jsonStr, &result)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test", result["name"])
|
||||
assert.Equal(t, float64(30), result["age"]) // JSON numbers are float64
|
||||
}
|
||||
|
||||
func TestToMap(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
s := TestStruct{Name: "test", Age: 30}
|
||||
m, err := utils.ToMap(s)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test", m["name"])
|
||||
assert.Equal(t, float64(30), m["age"]) // JSON conversion makes it float64
|
||||
}
|
||||
|
||||
func TestFromMap(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
m := map[string]interface{}{
|
||||
"name": "test",
|
||||
"age": 30,
|
||||
}
|
||||
|
||||
var result TestStruct
|
||||
err := utils.FromMap(m, &result)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test", result.Name)
|
||||
assert.Equal(t, 30, result.Age)
|
||||
}
|
||||
|
||||
func TestToString(t *testing.T) {
|
||||
assert.Equal(t, "test", utils.ToString("test"))
|
||||
assert.Equal(t, "42", utils.ToString(42))
|
||||
assert.Equal(t, "true", utils.ToString(true))
|
||||
}
|
||||
|
||||
func TestMergeMap(t *testing.T) {
|
||||
target := map[string]interface{}{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
}
|
||||
source := map[string]interface{}{
|
||||
"b": 3,
|
||||
"c": 4,
|
||||
}
|
||||
|
||||
result := utils.MergeMap(target, source)
|
||||
assert.Equal(t, 1, result["a"])
|
||||
assert.Equal(t, 3, result["b"]) // overwritten
|
||||
assert.Equal(t, 4, result["c"])
|
||||
}
|
||||
|
||||
func TestCloneMap(t *testing.T) {
|
||||
original := map[string]interface{}{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
}
|
||||
|
||||
cloned := utils.CloneMap(original)
|
||||
cloned["a"] = 999
|
||||
|
||||
assert.Equal(t, 1, original["a"]) // original unchanged
|
||||
assert.Equal(t, 999, cloned["a"])
|
||||
}
|
||||
|
||||
// Validate tests
|
||||
func TestIsEmpty(t *testing.T) {
|
||||
assert.True(t, utils.IsEmpty(""))
|
||||
assert.False(t, utils.IsEmpty("test"))
|
||||
}
|
||||
|
||||
func TestIsValidEmail(t *testing.T) {
|
||||
assert.True(t, utils.IsValidEmail("test@example.com"))
|
||||
assert.True(t, utils.IsValidEmail("user+tag@domain.co.uk"))
|
||||
assert.False(t, utils.IsValidEmail("invalid"))
|
||||
assert.False(t, utils.IsValidEmail("@example.com"))
|
||||
assert.False(t, utils.IsValidEmail("test@"))
|
||||
}
|
||||
|
||||
func TestIsValidTime(t *testing.T) {
|
||||
assert.True(t, utils.IsValidTime("09:00"))
|
||||
assert.True(t, utils.IsValidTime("14:30"))
|
||||
assert.True(t, utils.IsValidTime("23:59"))
|
||||
assert.False(t, utils.IsValidTime("25:00"))
|
||||
assert.False(t, utils.IsValidTime("14:65"))
|
||||
assert.False(t, utils.IsValidTime("14-30"))
|
||||
}
|
||||
|
||||
func TestValidateRequired(t *testing.T) {
|
||||
t.Run("nil value", func(t *testing.T) {
|
||||
err := utils.ValidateRequired("field", nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
err := utils.ValidateRequired("field", "")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid string", func(t *testing.T) {
|
||||
err := utils.ValidateRequired("field", "value")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
err := utils.ValidateRequired("field", []string{})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateRange(t *testing.T) {
|
||||
t.Run("within range", func(t *testing.T) {
|
||||
err := utils.ValidateRange("field", 5, 1, 10)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("below range", func(t *testing.T) {
|
||||
err := utils.ValidateRange("field", 0, 1, 10)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("above range", func(t *testing.T) {
|
||||
err := utils.ValidateRange("field", 11, 1, 10)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateOneOf(t *testing.T) {
|
||||
allowed := []string{"apple", "banana", "cherry"}
|
||||
|
||||
t.Run("valid value", func(t *testing.T) {
|
||||
err := utils.ValidateOneOf("field", "banana", allowed)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid value", func(t *testing.T) {
|
||||
err := utils.ValidateOneOf("field", "orange", allowed)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
87
agent/robot/utils/validate.go
Normal file
87
agent/robot/utils/validate.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
// Email regex pattern
|
||||
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// Time pattern (HH:MM)
|
||||
timeRegex = regexp.MustCompile(`^([01]?[0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
)
|
||||
|
||||
// IsEmpty checks if a string is empty or whitespace only
|
||||
func IsEmpty(s string) bool {
|
||||
return len(s) == 0
|
||||
}
|
||||
|
||||
// IsValidEmail validates email format
|
||||
func IsValidEmail(email string) bool {
|
||||
return emailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
// IsValidTime validates time format (HH:MM)
|
||||
func IsValidTime(timeStr string) bool {
|
||||
return timeRegex.MatchString(timeStr)
|
||||
}
|
||||
|
||||
// ValidateRequired checks if required fields are present
|
||||
func ValidateRequired(fieldName string, value interface{}) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if IsEmpty(v) {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
case []string:
|
||||
if len(v) == 0 {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
if len(v) == 0 {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRange checks if a number is within range
|
||||
func ValidateRange(fieldName string, value, min, max int) error {
|
||||
if value < min || value > max {
|
||||
return fmt.Errorf("%s must be between %d and %d", fieldName, min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOneOf checks if value is one of allowed values
|
||||
func ValidateOneOf(fieldName string, value string, allowed []string) error {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s must be one of: %v", fieldName, allowed)
|
||||
}
|
||||
|
||||
// ValidateEmail validates email and returns error if invalid
|
||||
func ValidateEmail(fieldName string, email string) error {
|
||||
if !IsValidEmail(email) {
|
||||
return fmt.Errorf("%s is not a valid email", fieldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTimeFormat validates time format (HH:MM)
|
||||
func ValidateTimeFormat(fieldName string, timeStr string) error {
|
||||
if !IsValidTime(timeStr) {
|
||||
return fmt.Errorf("%s must be in HH:MM format", fieldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue