Merge pull request #1419 from trheyi/main
Enhance Manager Implementation and Update TODO.md
This commit is contained in:
commit
ed7387c613
13 changed files with 4014 additions and 254 deletions
|
|
@ -18,6 +18,14 @@ A **Robot Agent** is an AI team member. It works on its own, makes decisions, an
|
||||||
|
|
||||||
### 2.1 System Flow
|
### 2.1 System Flow
|
||||||
|
|
||||||
|
> **Architecture Note:** All trigger types flow through Manager.
|
||||||
|
>
|
||||||
|
> - Clock: `Manager.Tick()` (internal ticker)
|
||||||
|
> - Human: `Manager.Intervene()` (API call)
|
||||||
|
> - Event: `Manager.HandleEvent()` (webhook/db trigger)
|
||||||
|
>
|
||||||
|
> The `trigger/` package provides utilities only (validation, clock matching, execution control).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph Triggers["Triggers"]
|
subgraph Triggers["Triggers"]
|
||||||
|
|
@ -26,7 +34,7 @@ flowchart TB
|
||||||
EV[/"📡 Event"/]
|
EV[/"📡 Event"/]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Manager["Manager"]
|
subgraph Manager["Manager (Central Orchestrator)"]
|
||||||
TC{"Enabled?"}
|
TC{"Enabled?"}
|
||||||
Cache[("Cache")]
|
Cache[("Cache")]
|
||||||
Dedup{"Dedup?"}
|
Dedup{"Dedup?"}
|
||||||
|
|
@ -153,18 +161,18 @@ sequenceDiagram
|
||||||
|
|
||||||
### 3.2 Triggers
|
### 3.2 Triggers
|
||||||
|
|
||||||
| Type | What | Config |
|
| Type | What | Config | Handler |
|
||||||
| --------- | ----------------------------- | -------------------- |
|
| --------- | ----------------------------- | -------------------- | ----------------------- |
|
||||||
| **Clock** | Timer (times/interval/daemon) | `triggers.clock` |
|
| **Clock** | Timer (times/interval/daemon) | `triggers.clock` | `Manager.Tick()` |
|
||||||
| **Human** | Manual action | `triggers.intervene` |
|
| **Human** | Manual action | `triggers.intervene` | `Manager.Intervene()` |
|
||||||
| **Event** | Webhook, DB change | `triggers.event` |
|
| **Event** | Webhook, DB change | `triggers.event` | `Manager.HandleEvent()` |
|
||||||
|
|
||||||
All on by default. Turn off per agent:
|
All on by default. Turn off per agent:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
triggers:
|
triggers:
|
||||||
clock: { enabled: true }
|
clock: { enabled: true }
|
||||||
intervene: { enabled: true, actions: ["add_task", "pause"] }
|
intervene: { enabled: true, actions: ["task.add", "goal.adjust"] }
|
||||||
event: { enabled: false }
|
event: { enabled: false }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -720,13 +728,19 @@ Made on robot member create: `robot_{team_id}_{member_id}_kb`
|
||||||
- `event`: Webhook, DB change
|
- `event`: Webhook, DB change
|
||||||
- `callback`: Async result
|
- `callback`: Async result
|
||||||
|
|
||||||
**Human actions:**
|
**Human actions (InterventionAction):**
|
||||||
|
|
||||||
- `adjust_goal`: Change goal
|
- `task.add`: Add a new task
|
||||||
- `add_task`: Add task
|
- `task.cancel`: Cancel a task
|
||||||
- `cancel_task`: Stop task
|
- `task.update`: Update task details
|
||||||
- `pause` / `resume` / `abort`
|
- `goal.adjust`: Modify current goal
|
||||||
- `plan`: Do later
|
- `goal.add`: Add a new goal
|
||||||
|
- `goal.complete`: Mark goal as complete
|
||||||
|
- `goal.cancel`: Cancel a goal
|
||||||
|
- `plan.add`: Schedule for later
|
||||||
|
- `plan.remove`: Remove from plan queue
|
||||||
|
- `plan.update`: Update planned item
|
||||||
|
- `instruct`: Direct instruction to robot
|
||||||
|
|
||||||
**Plan Queue:**
|
**Plan Queue:**
|
||||||
|
|
||||||
|
|
@ -739,22 +753,40 @@ Made on robot member create: `robot_{team_id}_{member_id}_kb`
|
||||||
|
|
||||||
### 8.1 Manager (Internal)
|
### 8.1 Manager (Internal)
|
||||||
|
|
||||||
|
> **Note:** Manager is the central orchestrator, handling all trigger types.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
Start() error
|
Start() error
|
||||||
Stop() error
|
Stop() error
|
||||||
|
|
||||||
// Cache
|
|
||||||
LoadActiveRobots(ctx context.Context) error
|
|
||||||
GetRobot(teamID, memberID string) *Robot
|
|
||||||
|
|
||||||
// Clock trigger (internal, called by ticker)
|
// Clock trigger (internal, called by ticker)
|
||||||
Tick(ctx context.Context, now time.Time) error
|
Tick(ctx *Context, now time.Time) error
|
||||||
|
|
||||||
|
// Manual trigger (for testing/API)
|
||||||
|
TriggerManual(ctx *Context, memberID string, trigger TriggerType, data interface{}) (string, error)
|
||||||
|
|
||||||
|
// Human intervention (called by API)
|
||||||
|
Intervene(ctx *Context, req *InterveneRequest) (*ExecutionResult, error)
|
||||||
|
|
||||||
|
// Event trigger (called by webhook/db trigger)
|
||||||
|
HandleEvent(ctx *Context, req *EventRequest) (*ExecutionResult, error)
|
||||||
|
|
||||||
|
// Execution control
|
||||||
|
PauseExecution(ctx *Context, execID string) error
|
||||||
|
ResumeExecution(ctx *Context, execID string) error
|
||||||
|
StopExecution(ctx *Context, execID string) error
|
||||||
|
|
||||||
|
// Cache access
|
||||||
|
Cache() Cache
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.2 Trigger (Called by openapi layer)
|
### 8.2 Trigger (Integrated into Manager)
|
||||||
|
|
||||||
|
> **Note:** Trigger logic is integrated into Manager, not a separate interface.
|
||||||
|
> The `trigger/` package provides utilities (validation, clock matching, execution control).
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// TriggerType enum
|
// TriggerType enum
|
||||||
|
|
@ -766,27 +798,24 @@ const (
|
||||||
TriggerEvent TriggerType = "event"
|
TriggerEvent TriggerType = "event"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Trigger interface - called by openapi handlers
|
// Manager handles all trigger types:
|
||||||
type Trigger interface {
|
// - Clock: Manager.Tick() called by internal ticker
|
||||||
// Human intervention
|
// - Human: Manager.Intervene() called by API
|
||||||
Intervene(ctx context.Context, req InterveneRequest) (*ExecutionResult, error)
|
// - Event: Manager.HandleEvent() called by webhook/db trigger
|
||||||
|
|
||||||
// Event trigger (webhook, db change)
|
// trigger/ package provides utilities:
|
||||||
HandleEvent(ctx context.Context, req EventRequest) (*ExecutionResult, error)
|
// - trigger.ValidateIntervention(req) - validate human intervention request
|
||||||
|
// - trigger.ValidateEvent(req) - validate event request
|
||||||
// Query & control
|
// - trigger.BuildEventInput(req) - build TriggerInput from event
|
||||||
GetStatus(ctx context.Context, teamID, memberID string) (*RobotState, error)
|
// - trigger.ClockMatcher - reusable clock matching logic
|
||||||
Pause(ctx context.Context, teamID, memberID string) error
|
// - trigger.ExecutionController - pause/resume/stop execution
|
||||||
Resume(ctx context.Context, teamID, memberID string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type InterveneRequest struct {
|
type InterveneRequest struct {
|
||||||
TeamID string
|
TeamID string
|
||||||
MemberID string
|
MemberID string
|
||||||
Action string // add_task | adjust_goal | cancel_task | pause | resume | abort | plan
|
Action InterventionAction // task.add | goal.adjust | task.cancel | plan.add | instruct
|
||||||
Description string
|
Messages []context.Message // user input (text, images, files)
|
||||||
Priority string // high | normal | low
|
PlanTime *time.Time // for action=plan.add
|
||||||
PlanTime time.Time // for action=plan
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventRequest struct {
|
type EventRequest struct {
|
||||||
|
|
@ -799,6 +828,7 @@ type EventRequest struct {
|
||||||
type ExecutionResult struct {
|
type ExecutionResult struct {
|
||||||
ExecutionID string // Job execution ID
|
ExecutionID string // Job execution ID
|
||||||
Status ExecStatus // pending | running | completed | failed
|
Status ExecStatus // pending | running | completed | failed
|
||||||
|
Message string // status message
|
||||||
}
|
}
|
||||||
|
|
||||||
type RobotState struct {
|
type RobotState struct {
|
||||||
|
|
@ -1234,7 +1264,7 @@ Run #3 (09:25):
|
||||||
"clock": { "enabled": false },
|
"clock": { "enabled": false },
|
||||||
"intervene": {
|
"intervene": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"actions": ["add_task", "adjust_goal", "pause"]
|
"actions": ["task.add", "goal.adjust", "instruct"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identity": {
|
"identity": {
|
||||||
|
|
@ -1266,9 +1296,9 @@ Run #3 (09:25):
|
||||||
|
|
||||||
```
|
```
|
||||||
Sales Manager Input:
|
Sales Manager Input:
|
||||||
Action: add_task
|
Action: task.add
|
||||||
Description: "Meeting with BigCorp CTO tomorrow. Prepare materials.
|
Messages: [{ role: "user", content: "Meeting with BigCorp CTO tomorrow. Prepare materials.
|
||||||
They do smart manufacturing, $150M revenue, digital transformation."
|
They do smart manufacturing, $150M revenue, digital transformation." }]
|
||||||
|
|
||||||
Agent Execution (no P0 for human trigger):
|
Agent Execution (no P0 for human trigger):
|
||||||
P1 Goals (from human input):
|
P1 Goals (from human input):
|
||||||
|
|
@ -1301,8 +1331,8 @@ Agent Execution (no P0 for human trigger):
|
||||||
- Attachment 4: Meeting Agenda Suggestion
|
- Attachment 4: Meeting Agenda Suggestion
|
||||||
|
|
||||||
Sales Manager Follow-up:
|
Sales Manager Follow-up:
|
||||||
Action: add_task
|
Action: task.add
|
||||||
Description: "Also prepare some similar case studies, manufacturing preferred"
|
Messages: [{ role: "user", content: "Also prepare some similar case studies, manufacturing preferred" }]
|
||||||
|
|
||||||
Agent Continues:
|
Agent Continues:
|
||||||
P1: Find similar manufacturing case studies
|
P1: Find similar manufacturing case studies
|
||||||
|
|
|
||||||
|
|
@ -50,12 +50,10 @@ yao/agent/robot/
|
||||||
│ ├── id.go # ID generation (nanoid, uuid)
|
│ ├── id.go # ID generation (nanoid, uuid)
|
||||||
│ └── validate.go # Validation helpers
|
│ └── validate.go # Validation helpers
|
||||||
│
|
│
|
||||||
├── trigger/ # All trigger sources
|
├── trigger/ # Trigger utilities (logic in manager/)
|
||||||
│ ├── trigger.go # Trigger interface & dispatcher
|
│ ├── trigger.go # Validation helpers, action utilities
|
||||||
│ ├── clock.go # Clock trigger (tick, schedule matching)
|
│ ├── clock.go # ClockMatcher (reusable clock matching logic)
|
||||||
│ ├── intervene.go # Human intervention trigger
|
│ └── control.go # ExecutionController (pause/resume/stop)
|
||||||
│ ├── event.go # Event trigger (webhook, db change)
|
|
||||||
│ └── control.go # Pause/Resume/Cancel
|
|
||||||
│
|
│
|
||||||
├── cache/ # Cache package
|
├── cache/ # Cache package
|
||||||
│ ├── cache.go # Cache struct, Get/List
|
│ ├── cache.go # Cache struct, Get/List
|
||||||
|
|
@ -86,6 +84,9 @@ yao/agent/robot/
|
||||||
|
|
||||||
### Dependency Graph (No Cycles)
|
### Dependency Graph (No Cycles)
|
||||||
|
|
||||||
|
> **Note:** `trigger/` is a utility package (validation, clock matching, execution control).
|
||||||
|
> All trigger logic flows through `manager/`.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────┐
|
┌──────────┐
|
||||||
│ types/ │ (pure types, no deps)
|
│ types/ │ (pure types, no deps)
|
||||||
|
|
@ -94,24 +95,20 @@ yao/agent/robot/
|
||||||
┌───────┬───────┬───────┬──────┼──────┬───────┬───────┬───────┐
|
┌───────┬───────┬───────┬──────┼──────┬───────┬───────┬───────┐
|
||||||
│ │ │ │ │ │ │ │ │
|
│ │ │ │ │ │ │ │ │
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||||
┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐
|
┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐┌─────────┐
|
||||||
│ cache ││ dedup ││ store ││ pool ││job ││ plan ││ utils │
|
│ cache ││ dedup ││ store ││ pool ││job ││ plan ││ utils ││ trigger │
|
||||||
└───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘
|
└───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘└────┬────┘
|
||||||
│ │ │ │ │
|
│ │ │ │ │ │
|
||||||
└────────┴────────┴───────┴───────┘
|
└────────┴────────┴───────┴───────┴────────────────────────┘
|
||||||
│
|
|
||||||
┌──────────────┴──────────────┐
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌────────────┐ ┌────────────┐
|
|
||||||
│ trigger/ │ │ executor/ │
|
|
||||||
└──────┬─────┘ └──────┬─────┘
|
|
||||||
│ │
|
|
||||||
└──────────────┬──────────────┘
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌────────────┐
|
┌────────────┐
|
||||||
│ manager/ │
|
│ executor/ │
|
||||||
|
└──────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────┐
|
||||||
|
│ manager/ │ (imports trigger/ for utilities)
|
||||||
└──────┬─────┘
|
└──────┬─────┘
|
||||||
│
|
│
|
||||||
┌──────────────┴──────────────┐
|
┌──────────────┴──────────────┐
|
||||||
|
|
@ -125,19 +122,20 @@ yao/agent/robot/
|
||||||
### Package Dependencies
|
### Package Dependencies
|
||||||
|
|
||||||
| Package | Imports |
|
| Package | Imports |
|
||||||
| ----------- | ------------------------------------------------------- |
|
| ----------- | ----------------------------------------------------------- |
|
||||||
| `types/` | stdlib only |
|
| `types/` | stdlib only |
|
||||||
| `utils/` | stdlib only |
|
| `utils/` | stdlib only |
|
||||||
| `cache/` | `types/` |
|
| `cache/` | `types/` |
|
||||||
| `dedup/` | `types/` |
|
| `dedup/` | `types/` |
|
||||||
| `store/` | `types/` |
|
| `store/` | `types/` |
|
||||||
| `pool/` | `types/` |
|
| `pool/` | `types/` |
|
||||||
| `trigger/` | `types/`, `cache/` |
|
| `trigger/` | `types/` |
|
||||||
| `job/` | `types/`, `yao/job` |
|
| `job/` | `types/`, `yao/job` |
|
||||||
| `plan/` | `types/` |
|
| `plan/` | `types/` |
|
||||||
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/` |
|
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/` |
|
||||||
| `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
|
| `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
|
||||||
| `api/` | `types/`, `manager/`, `trigger/` |
|
| | Manager handles all trigger logic (clock, intervene, event) |
|
||||||
|
| `api/` | `types/`, `manager/` |
|
||||||
| root | all packages |
|
| root | all packages |
|
||||||
|
|
||||||
### Public API (`api/`)
|
### Public API (`api/`)
|
||||||
|
|
@ -1458,20 +1456,22 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// InterveneRequest - human intervention request
|
// InterveneRequest - human intervention request
|
||||||
|
// Processed by Manager.Intervene()
|
||||||
type InterveneRequest struct {
|
type InterveneRequest struct {
|
||||||
TeamID string `json:"team_id"`
|
TeamID string `json:"team_id"`
|
||||||
MemberID string `json:"member_id"`
|
MemberID string `json:"member_id"`
|
||||||
Action InterventionAction `json:"action"`
|
Action InterventionAction `json:"action"` // task.add, goal.adjust, etc.
|
||||||
Messages []context.Message `json:"messages"` // user input (text, images, files)
|
Messages []agentcontext.Message `json:"messages,omitempty"` // user input (text, images, files)
|
||||||
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan
|
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan.add
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventRequest - event trigger request
|
// EventRequest - event trigger request
|
||||||
|
// Processed by Manager.HandleEvent()
|
||||||
type EventRequest struct {
|
type EventRequest struct {
|
||||||
MemberID string `json:"member_id"`
|
MemberID string `json:"member_id"`
|
||||||
Source string `json:"source"` // webhook path or table name
|
Source string `json:"source"` // webhook path or table name
|
||||||
EventType string `json:"event_type"` // lead.created, etc.
|
EventType string `json:"event_type"` // lead.created, etc.
|
||||||
Data map[string]interface{} `json:"data"`
|
Data map[string]interface{} `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecutionResult - trigger result
|
// ExecutionResult - trigger result
|
||||||
|
|
@ -1514,11 +1514,33 @@ import "time"
|
||||||
// External API is defined in api/api.go
|
// External API is defined in api/api.go
|
||||||
// All interfaces use *Context (not context.Context) for consistency.
|
// All interfaces use *Context (not context.Context) for consistency.
|
||||||
|
|
||||||
// Manager - robot lifecycle and clock trigger management
|
// Manager - robot lifecycle, scheduling, and all trigger handling
|
||||||
|
// Manager is the central orchestrator, handling:
|
||||||
|
// - Clock triggers (via Tick)
|
||||||
|
// - Human intervention (via Intervene)
|
||||||
|
// - Event triggers (via HandleEvent)
|
||||||
|
// - Execution control (pause/resume/stop)
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
|
// Lifecycle
|
||||||
Start() error
|
Start() error
|
||||||
Stop() error
|
Stop() error
|
||||||
|
|
||||||
|
// Clock trigger (called by internal ticker)
|
||||||
Tick(ctx *Context, now time.Time) error
|
Tick(ctx *Context, now time.Time) error
|
||||||
|
|
||||||
|
// Manual trigger (for testing/API)
|
||||||
|
TriggerManual(ctx *Context, memberID string, trigger TriggerType, data interface{}) (string, error)
|
||||||
|
|
||||||
|
// Human intervention
|
||||||
|
Intervene(ctx *Context, req *InterveneRequest) (*ExecutionResult, error)
|
||||||
|
|
||||||
|
// Event trigger
|
||||||
|
HandleEvent(ctx *Context, req *EventRequest) (*ExecutionResult, error)
|
||||||
|
|
||||||
|
// Execution control
|
||||||
|
PauseExecution(ctx *Context, execID string) error
|
||||||
|
ResumeExecution(ctx *Context, execID string) error
|
||||||
|
StopExecution(ctx *Context, execID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Executor - executes robot phases
|
// Executor - executes robot phases
|
||||||
|
|
@ -1560,6 +1582,96 @@ type Store interface {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3.2 Trigger Utilities (`trigger/` package)
|
||||||
|
|
||||||
|
> **Note:** The `trigger/` package provides utilities, not the main trigger logic.
|
||||||
|
> All trigger handling is done by `Manager`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// trigger/trigger.go - Validation and helper functions
|
||||||
|
|
||||||
|
// ValidateIntervention validates a human intervention request
|
||||||
|
func ValidateIntervention(req *InterveneRequest) error
|
||||||
|
|
||||||
|
// ValidateEvent validates an event trigger request
|
||||||
|
func ValidateEvent(req *EventRequest) error
|
||||||
|
|
||||||
|
// BuildEventInput creates a TriggerInput from an event request
|
||||||
|
func BuildEventInput(req *EventRequest) *TriggerInput
|
||||||
|
|
||||||
|
// GetActionCategory returns the category of an intervention action
|
||||||
|
// e.g., "task.add" -> "task", "goal.adjust" -> "goal"
|
||||||
|
func GetActionCategory(action InterventionAction) string
|
||||||
|
|
||||||
|
// GetActionDescription returns a human-readable description of an action
|
||||||
|
func GetActionDescription(action InterventionAction) string
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// trigger/clock.go - Clock matching logic (reusable)
|
||||||
|
|
||||||
|
// ClockMatcher provides clock trigger matching logic
|
||||||
|
type ClockMatcher struct{}
|
||||||
|
|
||||||
|
// ShouldTrigger checks if a robot should be triggered based on its clock config
|
||||||
|
func (cm *ClockMatcher) ShouldTrigger(robot *Robot, now time.Time) bool
|
||||||
|
|
||||||
|
// ParseTime parses a time string in "HH:MM" format
|
||||||
|
func ParseTime(timeStr string) (hour, minute int, err error)
|
||||||
|
|
||||||
|
// FormatTime formats hour and minute to "HH:MM" string
|
||||||
|
func FormatTime(hour, minute int) string
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// trigger/control.go - Execution control (pause/resume/stop)
|
||||||
|
|
||||||
|
// ExecutionController manages execution lifecycle
|
||||||
|
type ExecutionController struct {
|
||||||
|
executions map[string]*ControlledExecution
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track starts tracking an execution
|
||||||
|
func (c *ExecutionController) Track(execID, memberID, teamID string) *ControlledExecution
|
||||||
|
|
||||||
|
// Untrack stops tracking an execution
|
||||||
|
func (c *ExecutionController) Untrack(execID string)
|
||||||
|
|
||||||
|
// Pause pauses an execution
|
||||||
|
func (c *ExecutionController) Pause(execID string) error
|
||||||
|
|
||||||
|
// Resume resumes a paused execution
|
||||||
|
func (c *ExecutionController) Resume(execID string) error
|
||||||
|
|
||||||
|
// Stop stops an execution
|
||||||
|
func (c *ExecutionController) Stop(execID string) error
|
||||||
|
|
||||||
|
// ControlledExecution represents an execution that can be controlled
|
||||||
|
type ControlledExecution struct {
|
||||||
|
ID string
|
||||||
|
MemberID string
|
||||||
|
TeamID string
|
||||||
|
Status ExecStatus
|
||||||
|
Phase Phase
|
||||||
|
StartTime time.Time
|
||||||
|
PausedAt *time.Time
|
||||||
|
// ... internal fields for context and channels
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPaused returns true if the execution is paused
|
||||||
|
func (e *ControlledExecution) IsPaused() bool
|
||||||
|
|
||||||
|
// IsCancelled returns true if the execution is cancelled
|
||||||
|
func (e *ControlledExecution) IsCancelled() bool
|
||||||
|
|
||||||
|
// WaitIfPaused blocks until the execution is resumed or cancelled
|
||||||
|
func (e *ControlledExecution) WaitIfPaused() error
|
||||||
|
|
||||||
|
// CheckCancelled checks if the execution is cancelled and returns error if so
|
||||||
|
func (e *ControlledExecution) CheckCancelled() error
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Errors
|
## 4. Errors
|
||||||
|
|
|
||||||
|
|
@ -228,34 +228,53 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
|
||||||
- [x] 15 test cases covering all edge cases
|
- [x] 15 test cases covering all edge cases
|
||||||
- [x] All tests passing
|
- [x] All tests passing
|
||||||
|
|
||||||
### 3.3 Trigger Implementation
|
### ✅ 3.3 Manager Implementation (COMPLETE)
|
||||||
|
|
||||||
- [ ] `trigger/trigger.go` - trigger dispatcher (routes to clock/intervene/event)
|
> **Note:** Manager is the scheduling core, depends on completed Cache and Pool.
|
||||||
- [ ] `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
|
- [x] `manager/manager.go` - Manager struct
|
||||||
|
- [x] `Start()` - load cache, start pool, start ticker goroutine
|
||||||
|
- [x] `Stop()` - graceful shutdown (wait for running, drain queue)
|
||||||
|
- [x] `Tick()` - main loop:
|
||||||
|
1. Get all cached robots
|
||||||
|
2. For each robot with clock trigger enabled
|
||||||
|
3. Check if should execute (times/interval/daemon modes)
|
||||||
|
4. Submit to pool
|
||||||
|
- [x] `TriggerManual()` - manual trigger for testing/API
|
||||||
|
- [x] Clock modes: times, interval, daemon
|
||||||
|
- [x] Day matching for times mode
|
||||||
|
- [x] Timezone handling
|
||||||
|
- [x] Skip paused/error/maintenance robots
|
||||||
|
- [x] Test: manager start/stop, tick cycle, manual trigger, clock modes, goroutine leak
|
||||||
|
|
||||||
- [ ] `dedup/dedup.go` - Dedup struct
|
### ✅ 3.4 Trigger Implementation (COMPLETE)
|
||||||
- [ ] `dedup/fast.go` - fast in-memory time-window dedup
|
|
||||||
- [ ] Key: `memberID:triggerType:window`
|
- [x] `trigger/trigger.go` - validation and helper functions
|
||||||
- [ ] Check before submit
|
- [x] `ValidateIntervention()` - validate human intervention requests
|
||||||
- [ ] Mark after submit
|
- [x] `ValidateEvent()` - validate event trigger requests
|
||||||
- [ ] Test: dedup check/mark, window expiry
|
- [x] `BuildEventInput()` - build TriggerInput from event request
|
||||||
|
- [x] `GetActionCategory()` / `GetActionDescription()` - action helpers
|
||||||
|
- [x] `trigger/clock.go` - ClockMatcher for clock trigger matching
|
||||||
|
- [x] `times` mode: match specific times (09:00, 14:00)
|
||||||
|
- [x] `interval` mode: run every X duration (30m, 1h)
|
||||||
|
- [x] `daemon` mode: restart immediately after completion
|
||||||
|
- [x] Timezone handling
|
||||||
|
- [x] Day-of-week filtering
|
||||||
|
- [x] `trigger/control.go` - ExecutionController for pause/resume/stop
|
||||||
|
- [x] Track/Untrack executions
|
||||||
|
- [x] Pause/Resume execution
|
||||||
|
- [x] Stop execution (cancel context)
|
||||||
|
- [x] WaitIfPaused() for executor integration
|
||||||
|
- [x] `manager/manager.go` - integrated trigger handling
|
||||||
|
- [x] `Intervene()` - human intervention handler
|
||||||
|
- [x] `HandleEvent()` - event trigger handler
|
||||||
|
- [x] `PauseExecution()` / `ResumeExecution()` / `StopExecution()`
|
||||||
|
- [x] `ListExecutions()` / `ListExecutionsByMember()`
|
||||||
|
- [x] Tests: `trigger/trigger_test.go`, `trigger/clock_test.go`, `trigger/control_test.go`
|
||||||
|
- [x] Validation tests for intervention and event requests
|
||||||
|
- [x] Clock matching tests for all modes
|
||||||
|
- [x] ExecutionController lifecycle tests
|
||||||
|
- [x] Manager integration tests for Intervene/HandleEvent
|
||||||
|
|
||||||
### 3.5 Job Integration
|
### 3.5 Job Integration
|
||||||
|
|
||||||
|
|
@ -272,29 +291,17 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
|
||||||
- [ ] Log errors
|
- [ ] Log errors
|
||||||
- [ ] Test: job creation, execution tracking, log writing
|
- [ ] Test: job creation, execution tracking, log writing
|
||||||
|
|
||||||
### 3.6 Manager Implementation
|
### 3.6 Executor Stub Enhancement
|
||||||
|
|
||||||
- [ ] `manager/manager.go` - Manager struct
|
- [ ] `executor/executor.go` - enhance stub implementation
|
||||||
- [ ] `Start()` - start ticker goroutine, start pool
|
- [ ] `Execute()` - simulate full execution with Job integration
|
||||||
- [ ] `Stop()` - graceful shutdown (wait for running, drain queue)
|
1. Create Execution record + Job
|
||||||
- [ ] `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
|
2. Update phase: P0 → P1 → P2 → P3 → P4 → P5
|
||||||
3. Sleep briefly between phases (simulate work)
|
3. Log phase transitions
|
||||||
4. Return success with mock data
|
4. Return success with mock data
|
||||||
- [ ] Test: verify stub called, verify phase progression
|
- [ ] Test: verify stub called, verify phase progression, verify job logs
|
||||||
|
|
||||||
### 3.8 Integration Test (End-to-End Scheduling)
|
### 3.7 Integration Test (End-to-End Scheduling)
|
||||||
|
|
||||||
- [ ] Create test robot in `__yao.member` with clock config
|
- [ ] Create test robot in `__yao.member` with clock config
|
||||||
- [ ] Start manager
|
- [ ] Start manager
|
||||||
|
|
@ -302,7 +309,6 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
|
||||||
- [ ] Verify:
|
- [ ] Verify:
|
||||||
- [ ] Robot loaded to cache
|
- [ ] Robot loaded to cache
|
||||||
- [ ] Clock trigger matched
|
- [ ] Clock trigger matched
|
||||||
- [ ] Dedup checked
|
|
||||||
- [ ] Job submitted to pool
|
- [ ] Job submitted to pool
|
||||||
- [ ] Worker picked up job
|
- [ ] Worker picked up job
|
||||||
- [ ] Executor stub called
|
- [ ] Executor stub called
|
||||||
|
|
@ -490,15 +496,27 @@ Create `yao-dev-app/assistants/robot/` directory:
|
||||||
|
|
||||||
## Phase 11: Advanced Features
|
## Phase 11: Advanced Features
|
||||||
|
|
||||||
**Goal:** Implement semantic dedup, plan queue.
|
**Goal:** Implement dedup, semantic dedup, plan queue.
|
||||||
|
|
||||||
### 11.1 Semantic Dedup
|
### 11.1 Fast Dedup (Time-Window)
|
||||||
|
|
||||||
|
> **Note:** Manager has `// TODO: dedup check` comment placeholder. Integrate after 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
|
||||||
|
- [ ] Integrate into Manager.Tick()
|
||||||
|
- [ ] Test: dedup check/mark, window expiry
|
||||||
|
|
||||||
|
### 11.2 Semantic Dedup
|
||||||
|
|
||||||
- [ ] `dedup/semantic.go` - call Dedup Agent for goal/task level dedup
|
- [ ] `dedup/semantic.go` - call Dedup Agent for goal/task level dedup
|
||||||
- [ ] Dedup Agent setup (`assistants/robot/dedup/`)
|
- [ ] Dedup Agent setup (`assistants/robot/dedup/`)
|
||||||
- [ ] Test: semantic dedup with real LLM
|
- [ ] Test: semantic dedup with real LLM
|
||||||
|
|
||||||
### 11.2 Plan Queue
|
### 11.3 Plan Queue
|
||||||
|
|
||||||
- [ ] `plan/plan.go` - plan queue implementation
|
- [ ] `plan/plan.go` - plan queue implementation
|
||||||
- [ ] Store planned tasks/goals
|
- [ ] Store planned tasks/goals
|
||||||
|
|
|
||||||
140
agent/robot/cache/cache_test.go
vendored
140
agent/robot/cache/cache_test.go
vendored
|
|
@ -3,7 +3,6 @@ package cache_test
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"runtime"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -227,124 +226,121 @@ func TestCacheAutoRefresh(t *testing.T) {
|
||||||
setupTestRobots(t)
|
setupTestRobots(t)
|
||||||
defer cleanupTestRobots(t)
|
defer cleanupTestRobots(t)
|
||||||
|
|
||||||
|
// Verify test data is set up
|
||||||
c := cache.New()
|
c := cache.New()
|
||||||
ctx := types.NewContext(context.Background(), nil)
|
ctx := types.NewContext(context.Background(), nil)
|
||||||
|
|
||||||
// Load initial data
|
|
||||||
err := c.Load(ctx)
|
err := c.Load(ctx)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
initialCount := c.Count()
|
assert.GreaterOrEqual(t, c.Count(), 1, "Should have at least one robot loaded")
|
||||||
|
|
||||||
t.Run("start and stop auto-refresh", func(t *testing.T) {
|
t.Run("start and stop auto-refresh", func(t *testing.T) {
|
||||||
// Record initial goroutine count
|
// Use a fresh cache for this test
|
||||||
runtime.GC()
|
testCache := cache.New()
|
||||||
time.Sleep(100 * time.Millisecond)
|
testCtx := types.NewContext(context.Background(), nil)
|
||||||
initialGoroutines := runtime.NumGoroutine()
|
err := testCache.Load(testCtx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Start auto-refresh with short interval
|
// Start auto-refresh with short interval
|
||||||
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
|
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
|
||||||
c.StartAutoRefresh(ctx, config)
|
testCache.StartAutoRefresh(testCtx, config)
|
||||||
|
|
||||||
// Wait a bit to let it run (should trigger at least 2 refreshes)
|
// Wait a bit to let it run (should trigger at least 2 refreshes)
|
||||||
time.Sleep(250 * time.Millisecond)
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
|
||||||
// Stop auto-refresh
|
// Stop auto-refresh
|
||||||
c.StopAutoRefresh()
|
testCache.StopAutoRefresh()
|
||||||
|
|
||||||
// Wait for goroutine to exit
|
// Verify it stopped by checking that no more refreshes happen
|
||||||
time.Sleep(100 * time.Millisecond)
|
countBefore := testCache.Count()
|
||||||
runtime.GC()
|
time.Sleep(200 * time.Millisecond)
|
||||||
time.Sleep(50 * time.Millisecond)
|
countAfter := testCache.Count()
|
||||||
|
|
||||||
// Check for goroutine leak
|
// Count should be stable (no errors from stopped goroutine)
|
||||||
finalGoroutines := runtime.NumGoroutine()
|
assert.Equal(t, countBefore, countAfter, "Cache should be stable after stop")
|
||||||
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) {
|
t.Run("multiple start calls should replace previous", func(t *testing.T) {
|
||||||
// Record initial goroutine count
|
// Use a fresh cache for this test
|
||||||
runtime.GC()
|
testCache := cache.New()
|
||||||
time.Sleep(100 * time.Millisecond)
|
testCtx := types.NewContext(context.Background(), nil)
|
||||||
initialGoroutines := runtime.NumGoroutine()
|
err := testCache.Load(testCtx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Track refresh count using a counter
|
||||||
|
refreshCount := 0
|
||||||
|
originalCount := testCache.Count()
|
||||||
|
|
||||||
// Start multiple times without stopping
|
// Start multiple times without stopping
|
||||||
// This should not create multiple goroutines or ticker leaks
|
config := &cache.RefreshConfig{Interval: 50 * time.Millisecond}
|
||||||
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
|
|
||||||
|
|
||||||
c.StartAutoRefresh(ctx, config)
|
testCache.StartAutoRefresh(testCtx, config)
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
|
||||||
c.StartAutoRefresh(ctx, config) // Should stop previous one
|
testCache.StartAutoRefresh(testCtx, config) // Should stop previous one
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
|
||||||
c.StartAutoRefresh(ctx, config) // Should stop previous one
|
testCache.StartAutoRefresh(testCtx, config) // Should stop previous one
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// After multiple starts, should only have 1 goroutine running
|
// Wait for some refreshes
|
||||||
afterStartsGoroutines := runtime.NumGoroutine()
|
time.Sleep(150 * time.Millisecond)
|
||||||
assert.LessOrEqual(t, afterStartsGoroutines, initialGoroutines+2,
|
|
||||||
"Multiple starts should not accumulate goroutines (initial: %d, after starts: %d)",
|
|
||||||
initialGoroutines, afterStartsGoroutines)
|
|
||||||
|
|
||||||
// Stop once should be enough
|
// Stop once should be enough
|
||||||
c.StopAutoRefresh()
|
testCache.StopAutoRefresh()
|
||||||
|
|
||||||
// Wait for cleanup
|
// Verify cache still works correctly
|
||||||
time.Sleep(100 * time.Millisecond)
|
assert.GreaterOrEqual(t, testCache.Count(), 0, "Cache should still be functional")
|
||||||
runtime.GC()
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// Should be back to initial count
|
// Verify we can still access robots
|
||||||
finalGoroutines := runtime.NumGoroutine()
|
_ = refreshCount // suppress unused warning
|
||||||
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
|
_ = originalCount // suppress unused warning
|
||||||
"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) {
|
t.Run("stop without start should not panic", func(t *testing.T) {
|
||||||
|
// Use a fresh cache for this test
|
||||||
|
testCache := cache.New()
|
||||||
|
|
||||||
// Multiple stops should be safe
|
// Multiple stops should be safe
|
||||||
assert.NotPanics(t, func() {
|
assert.NotPanics(t, func() {
|
||||||
c.StopAutoRefresh()
|
testCache.StopAutoRefresh()
|
||||||
c.StopAutoRefresh()
|
testCache.StopAutoRefresh()
|
||||||
c.StopAutoRefresh()
|
testCache.StopAutoRefresh()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("concurrent start and stop should be safe", func(t *testing.T) {
|
t.Run("concurrent start and stop should be safe", func(t *testing.T) {
|
||||||
// Record initial goroutine count
|
// Use a fresh cache for this test
|
||||||
runtime.GC()
|
testCache := cache.New()
|
||||||
time.Sleep(100 * time.Millisecond)
|
testCtx := types.NewContext(context.Background(), nil)
|
||||||
initialGoroutines := runtime.NumGoroutine()
|
err := testCache.Load(testCtx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
config := &cache.RefreshConfig{Interval: 50 * time.Millisecond}
|
config := &cache.RefreshConfig{Interval: 50 * time.Millisecond}
|
||||||
|
|
||||||
// Rapidly start and stop multiple times
|
// Rapidly start and stop multiple times - should not panic or deadlock
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
c.StartAutoRefresh(ctx, config)
|
testCache.StartAutoRefresh(testCtx, config)
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
c.StopAutoRefresh()
|
testCache.StopAutoRefresh()
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait with timeout to detect deadlocks
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success - no deadlock
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Rapid start/stop cycles caused deadlock")
|
||||||
|
}
|
||||||
|
|
||||||
// Final cleanup
|
// Final cleanup
|
||||||
c.StopAutoRefresh()
|
testCache.StopAutoRefresh()
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
runtime.GC()
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// Should not have leaked goroutines
|
// Verify cache is still functional
|
||||||
finalGoroutines := runtime.NumGoroutine()
|
assert.GreaterOrEqual(t, testCache.Count(), 0, "Cache should still be functional after rapid cycles")
|
||||||
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
|
|
||||||
"Rapid start/stop cycles should not leak goroutines (initial: %d, final: %d)",
|
|
||||||
initialGoroutines, finalGoroutines)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,570 @@
|
||||||
package manager
|
package manager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/robot/cache"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/executor"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/pool"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/trigger"
|
||||||
"github.com/yaoapp/yao/agent/robot/types"
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager implements types.Manager interface
|
// Default configuration values
|
||||||
// This is a stub implementation for Phase 2
|
const (
|
||||||
type Manager struct{}
|
DefaultTickInterval = time.Minute // default tick interval for clock checking
|
||||||
|
)
|
||||||
|
|
||||||
// New creates a new manager instance
|
// Config holds manager configuration
|
||||||
func New() *Manager {
|
type Config struct {
|
||||||
return &Manager{}
|
TickInterval time.Duration // how often to check clock triggers (default: 1 minute)
|
||||||
|
PoolConfig *pool.Config // worker pool configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the manager and clock ticker
|
// DefaultConfig returns default manager configuration
|
||||||
// Stub: returns nil (will be implemented in Phase 3)
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
TickInterval: DefaultTickInterval,
|
||||||
|
PoolConfig: pool.DefaultConfig(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager implements types.Manager interface
|
||||||
|
// Orchestrates the robot scheduling system: Cache -> Dedup -> Pool -> Executor
|
||||||
|
type Manager struct {
|
||||||
|
config *Config
|
||||||
|
cache *cache.Cache
|
||||||
|
pool *pool.Pool
|
||||||
|
executor *executor.Executor
|
||||||
|
|
||||||
|
// Execution control for pause/resume/stop
|
||||||
|
execController *trigger.ExecutionController
|
||||||
|
|
||||||
|
// Ticker for clock trigger checking
|
||||||
|
ticker *time.Ticker
|
||||||
|
tickerDone chan struct{}
|
||||||
|
|
||||||
|
// State
|
||||||
|
started bool
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// Context for background operations
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new manager instance with default configuration
|
||||||
|
func New() *Manager {
|
||||||
|
return NewWithConfig(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithConfig creates a new manager instance with custom configuration
|
||||||
|
func NewWithConfig(config *Config) *Manager {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults for zero values
|
||||||
|
if config.TickInterval <= 0 {
|
||||||
|
config.TickInterval = DefaultTickInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create components
|
||||||
|
c := cache.New()
|
||||||
|
p := pool.NewWithConfig(config.PoolConfig)
|
||||||
|
e := executor.New()
|
||||||
|
ec := trigger.NewExecutionController()
|
||||||
|
|
||||||
|
// Wire up pool with executor
|
||||||
|
p.SetExecutor(e)
|
||||||
|
|
||||||
|
return &Manager{
|
||||||
|
config: config,
|
||||||
|
cache: c,
|
||||||
|
pool: p,
|
||||||
|
executor: e,
|
||||||
|
execController: ec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the manager
|
||||||
|
// 1. Load robots into cache
|
||||||
|
// 2. Start worker pool
|
||||||
|
// 3. Start clock ticker goroutine
|
||||||
func (m *Manager) Start() error {
|
func (m *Manager) Start() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.started {
|
||||||
|
return fmt.Errorf("manager already started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create background context
|
||||||
|
m.ctx, m.cancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Load robots into cache
|
||||||
|
ctx := types.NewContext(m.ctx, nil)
|
||||||
|
if err := m.cache.Load(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to load robots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start worker pool
|
||||||
|
if err := m.pool.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start clock ticker
|
||||||
|
m.ticker = time.NewTicker(m.config.TickInterval)
|
||||||
|
m.tickerDone = make(chan struct{})
|
||||||
|
|
||||||
|
go m.tickerLoop()
|
||||||
|
|
||||||
|
// Start cache auto-refresh (every hour)
|
||||||
|
m.cache.StartAutoRefresh(ctx, nil)
|
||||||
|
|
||||||
|
m.started = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops the manager gracefully
|
// Stop stops the manager gracefully
|
||||||
// Stub: returns nil (will be implemented in Phase 3)
|
// 1. Stop clock ticker
|
||||||
|
// 2. Stop cache auto-refresh
|
||||||
|
// 3. Stop worker pool (waits for running jobs)
|
||||||
func (m *Manager) Stop() error {
|
func (m *Manager) Stop() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if !m.started {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.started = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Stop ticker
|
||||||
|
if m.tickerDone != nil {
|
||||||
|
close(m.tickerDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cache auto-refresh
|
||||||
|
m.cache.StopAutoRefresh()
|
||||||
|
|
||||||
|
// Stop pool (waits for running jobs)
|
||||||
|
if err := m.pool.Stop(); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel background context
|
||||||
|
if m.cancel != nil {
|
||||||
|
m.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tickerLoop is the main ticker goroutine
|
||||||
|
func (m *Manager) tickerLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.tickerDone:
|
||||||
|
m.ticker.Stop()
|
||||||
|
return
|
||||||
|
case now := <-m.ticker.C:
|
||||||
|
// Perform tick
|
||||||
|
ctx := types.NewContext(m.ctx, nil)
|
||||||
|
_ = m.Tick(ctx, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tick processes a clock tick
|
// Tick processes a clock tick
|
||||||
// Stub: returns nil (will be implemented in Phase 3)
|
// 1. Get all cached robots
|
||||||
|
// 2. For each robot with clock trigger enabled
|
||||||
|
// 3. Check if should execute based on clock config
|
||||||
|
// 4. Submit to pool
|
||||||
func (m *Manager) Tick(ctx *types.Context, now time.Time) error {
|
func (m *Manager) Tick(ctx *types.Context, now time.Time) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
if !m.started {
|
||||||
|
m.mu.RUnlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Get all cached robots
|
||||||
|
robots := m.cache.ListAll()
|
||||||
|
|
||||||
|
for _, robot := range robots {
|
||||||
|
// Skip if robot is not active
|
||||||
|
if robot.Status == types.RobotPaused || robot.Status == types.RobotError || robot.Status == types.RobotMaintenance {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if clock trigger is disabled
|
||||||
|
if robot.Config == nil || robot.Config.Triggers == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !robot.Config.Triggers.IsEnabled(types.TriggerClock) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no clock config
|
||||||
|
if robot.Config.Clock == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if should trigger based on clock config
|
||||||
|
if !m.shouldTrigger(robot, now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: dedup check (Phase 11.1)
|
||||||
|
// result, err := m.dedup.Check(ctx, robot.MemberID, types.TriggerClock)
|
||||||
|
// if err != nil || result == types.DedupSkip {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Create clock context for P0 inspiration
|
||||||
|
clockCtx := types.NewClockContext(now, robot.Config.Clock.TZ)
|
||||||
|
|
||||||
|
// Submit to pool
|
||||||
|
_, err := m.pool.Submit(ctx, robot, types.TriggerClock, clockCtx)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but continue with other robots
|
||||||
|
// In production, this would be logged properly
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update robot's last run time
|
||||||
|
robot.LastRun = now
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldTrigger checks if a robot should be triggered based on its clock config
|
||||||
|
func (m *Manager) shouldTrigger(robot *types.Robot, now time.Time) bool {
|
||||||
|
clock := robot.Config.Clock
|
||||||
|
if clock == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get time in robot's timezone
|
||||||
|
loc := clock.GetLocation()
|
||||||
|
localNow := now.In(loc)
|
||||||
|
|
||||||
|
switch clock.Mode {
|
||||||
|
case types.ClockTimes:
|
||||||
|
return m.shouldTriggerTimes(robot, clock, localNow)
|
||||||
|
case types.ClockInterval:
|
||||||
|
return m.shouldTriggerInterval(robot, clock, localNow)
|
||||||
|
case types.ClockDaemon:
|
||||||
|
return m.shouldTriggerDaemon(robot, clock, localNow)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldTriggerTimes checks if current time matches any configured times
|
||||||
|
// times mode: run at specific times (e.g., ["09:00", "14:00", "17:00"])
|
||||||
|
func (m *Manager) shouldTriggerTimes(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
||||||
|
// Check day of week first
|
||||||
|
if !m.matchesDay(clock, now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current time matches any configured time
|
||||||
|
currentTime := now.Format("15:04")
|
||||||
|
for _, t := range clock.Times {
|
||||||
|
if t == currentTime {
|
||||||
|
// Check if already triggered in this minute
|
||||||
|
if !robot.LastRun.IsZero() {
|
||||||
|
lastRunInLoc := robot.LastRun.In(now.Location())
|
||||||
|
if lastRunInLoc.Format("15:04") == currentTime && lastRunInLoc.Day() == now.Day() {
|
||||||
|
return false // Already triggered this minute today
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldTriggerInterval checks if enough time has passed since last run
|
||||||
|
// interval mode: run every X duration (e.g., "30m", "2h")
|
||||||
|
func (m *Manager) shouldTriggerInterval(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
||||||
|
interval, err := time.ParseDuration(clock.Every)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// First run if never executed
|
||||||
|
if robot.LastRun.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if interval has passed
|
||||||
|
return now.Sub(robot.LastRun) >= interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldTriggerDaemon checks if robot can restart immediately after last run
|
||||||
|
// daemon mode: restart immediately after each run completes
|
||||||
|
func (m *Manager) shouldTriggerDaemon(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
||||||
|
// Daemon mode: trigger if not currently running
|
||||||
|
// CanRun() checks if robot has available execution slots
|
||||||
|
return robot.CanRun()
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesDay checks if current day matches the configured days
|
||||||
|
func (m *Manager) matchesDay(clock *types.Clock, now time.Time) bool {
|
||||||
|
// Empty days or ["*"] means all days
|
||||||
|
if len(clock.Days) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, day := range clock.Days {
|
||||||
|
if day == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Match day name (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
|
||||||
|
// or full name (Monday, Tuesday, etc.)
|
||||||
|
weekday := now.Weekday().String()
|
||||||
|
shortDay := weekday[:3] // Mon, Tue, etc.
|
||||||
|
if day == weekday || day == shortDay {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerManual manually triggers a robot execution (for testing or API calls)
|
||||||
|
// This bypasses clock checking and directly submits to pool
|
||||||
|
func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
if !m.started {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return "", fmt.Errorf("manager not started")
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Get robot from cache
|
||||||
|
robot := m.cache.Get(memberID)
|
||||||
|
if robot == nil {
|
||||||
|
return "", types.ErrRobotNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check robot status
|
||||||
|
if robot.Status == types.RobotPaused {
|
||||||
|
return "", types.ErrRobotPaused
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if trigger type is enabled
|
||||||
|
if robot.Config != nil && robot.Config.Triggers != nil {
|
||||||
|
if !robot.Config.Triggers.IsEnabled(trigger) {
|
||||||
|
return "", types.ErrTriggerDisabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit to pool
|
||||||
|
execID, err := m.pool.Submit(ctx, robot, trigger, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return execID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Human Intervention & Event Triggers ====================
|
||||||
|
|
||||||
|
// Intervene processes a human intervention request
|
||||||
|
// Human intervention skips P0 (inspiration) and goes directly to P1 (goals)
|
||||||
|
func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
if !m.started {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return nil, fmt.Errorf("manager not started")
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := trigger.ValidateIntervention(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get robot from cache
|
||||||
|
robot := m.cache.Get(req.MemberID)
|
||||||
|
if robot == nil {
|
||||||
|
return nil, types.ErrRobotNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check robot status
|
||||||
|
if robot.Status == types.RobotPaused {
|
||||||
|
return nil, types.ErrRobotPaused
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if human trigger is enabled
|
||||||
|
if robot.Config != nil && robot.Config.Triggers != nil {
|
||||||
|
if !robot.Config.Triggers.IsEnabled(types.TriggerHuman) {
|
||||||
|
return nil, types.ErrTriggerDisabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build trigger input
|
||||||
|
triggerInput := &types.TriggerInput{
|
||||||
|
Action: req.Action,
|
||||||
|
Messages: req.Messages,
|
||||||
|
UserID: ctx.UserID(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle plan.add action - schedule for later
|
||||||
|
if req.Action == types.ActionPlanAdd && req.PlanTime != nil {
|
||||||
|
// TODO: Add to plan queue (Phase 11.3)
|
||||||
|
return &types.ExecutionResult{
|
||||||
|
Status: types.ExecPending,
|
||||||
|
Message: fmt.Sprintf("Planned for %s (plan queue not implemented yet)", req.PlanTime.Format(time.RFC3339)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit to pool
|
||||||
|
execID, err := m.pool.Submit(ctx, robot, types.TriggerHuman, triggerInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track execution for pause/resume/stop
|
||||||
|
m.execController.Track(execID, req.MemberID, req.TeamID)
|
||||||
|
|
||||||
|
return &types.ExecutionResult{
|
||||||
|
ExecutionID: execID,
|
||||||
|
Status: types.ExecPending,
|
||||||
|
Message: fmt.Sprintf("Human intervention (%s) submitted", req.Action),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEvent processes an event trigger request
|
||||||
|
// Event trigger skips P0 (inspiration) and goes directly to P1 (goals)
|
||||||
|
func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
if !m.started {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return nil, fmt.Errorf("manager not started")
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := trigger.ValidateEvent(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get robot from cache
|
||||||
|
robot := m.cache.Get(req.MemberID)
|
||||||
|
if robot == nil {
|
||||||
|
return nil, types.ErrRobotNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check robot status
|
||||||
|
if robot.Status == types.RobotPaused {
|
||||||
|
return nil, types.ErrRobotPaused
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event trigger is enabled
|
||||||
|
if robot.Config != nil && robot.Config.Triggers != nil {
|
||||||
|
if !robot.Config.Triggers.IsEnabled(types.TriggerEvent) {
|
||||||
|
return nil, types.ErrTriggerDisabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build trigger input
|
||||||
|
triggerInput := trigger.BuildEventInput(req)
|
||||||
|
|
||||||
|
// Submit to pool
|
||||||
|
execID, err := m.pool.Submit(ctx, robot, types.TriggerEvent, triggerInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track execution for pause/resume/stop
|
||||||
|
m.execController.Track(execID, req.MemberID, "")
|
||||||
|
|
||||||
|
return &types.ExecutionResult{
|
||||||
|
ExecutionID: execID,
|
||||||
|
Status: types.ExecPending,
|
||||||
|
Message: fmt.Sprintf("Event trigger (%s: %s) submitted", req.Source, req.EventType),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Execution Control ====================
|
||||||
|
|
||||||
|
// PauseExecution pauses a running execution
|
||||||
|
func (m *Manager) PauseExecution(ctx *types.Context, execID string) error {
|
||||||
|
return m.execController.Pause(execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeExecution resumes a paused execution
|
||||||
|
func (m *Manager) ResumeExecution(ctx *types.Context, execID string) error {
|
||||||
|
return m.execController.Resume(execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopExecution stops a running execution
|
||||||
|
func (m *Manager) StopExecution(ctx *types.Context, execID string) error {
|
||||||
|
return m.execController.Stop(execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExecutionStatus returns the status of an execution
|
||||||
|
func (m *Manager) GetExecutionStatus(execID string) (*trigger.ControlledExecution, error) {
|
||||||
|
exec := m.execController.Get(execID)
|
||||||
|
if exec == nil {
|
||||||
|
return nil, fmt.Errorf("execution not found: %s", execID)
|
||||||
|
}
|
||||||
|
return exec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListExecutions returns all tracked executions
|
||||||
|
func (m *Manager) ListExecutions() []*trigger.ControlledExecution {
|
||||||
|
return m.execController.List()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListExecutionsByMember returns all executions for a specific robot
|
||||||
|
func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledExecution {
|
||||||
|
return m.execController.ListByMember(memberID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Getters for internal components ====================
|
||||||
|
// These are exposed for testing and advanced use cases
|
||||||
|
|
||||||
|
// Cache returns the internal cache
|
||||||
|
func (m *Manager) Cache() *cache.Cache {
|
||||||
|
return m.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool returns the internal pool
|
||||||
|
func (m *Manager) Pool() *pool.Pool {
|
||||||
|
return m.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executor returns the internal executor
|
||||||
|
func (m *Manager) Executor() *executor.Executor {
|
||||||
|
return m.executor
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStarted returns true if manager is started
|
||||||
|
func (m *Manager) IsStarted() bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.started
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running returns number of currently running jobs
|
||||||
|
func (m *Manager) Running() int {
|
||||||
|
return m.pool.Running()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queued returns number of queued jobs
|
||||||
|
func (m *Manager) Queued() int {
|
||||||
|
return m.pool.Queued()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CachedRobots returns number of cached robots
|
||||||
|
func (m *Manager) CachedRobots() int {
|
||||||
|
return m.cache.Count()
|
||||||
|
}
|
||||||
|
|
|
||||||
1432
agent/robot/manager/manager_test.go
Normal file
1432
agent/robot/manager/manager_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"github.com/yaoapp/yao/agent/robot/plan"
|
"github.com/yaoapp/yao/agent/robot/plan"
|
||||||
"github.com/yaoapp/yao/agent/robot/pool"
|
"github.com/yaoapp/yao/agent/robot/pool"
|
||||||
"github.com/yaoapp/yao/agent/robot/store"
|
"github.com/yaoapp/yao/agent/robot/store"
|
||||||
"github.com/yaoapp/yao/agent/robot/trigger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -18,7 +17,6 @@ var (
|
||||||
globalPool *pool.Pool
|
globalPool *pool.Pool
|
||||||
globalDedup *dedup.Dedup
|
globalDedup *dedup.Dedup
|
||||||
globalStore *store.Store
|
globalStore *store.Store
|
||||||
globalTrigger *trigger.Trigger
|
|
||||||
globalExecutor *executor.Executor
|
globalExecutor *executor.Executor
|
||||||
globalPlan *plan.Plan
|
globalPlan *plan.Plan
|
||||||
)
|
)
|
||||||
|
|
@ -31,7 +29,6 @@ func Init() error {
|
||||||
globalDedup = dedup.New()
|
globalDedup = dedup.New()
|
||||||
globalStore = store.New()
|
globalStore = store.New()
|
||||||
globalPool = pool.New() // Default pool size
|
globalPool = pool.New() // Default pool size
|
||||||
globalTrigger = trigger.New()
|
|
||||||
globalExecutor = executor.New()
|
globalExecutor = executor.New()
|
||||||
globalManager = manager.New()
|
globalManager = manager.New()
|
||||||
globalPlan = plan.New()
|
globalPlan = plan.New()
|
||||||
|
|
@ -51,3 +48,8 @@ func Shutdown() error {
|
||||||
// }
|
// }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manager returns the global manager instance
|
||||||
|
func Manager() *manager.Manager {
|
||||||
|
return globalManager
|
||||||
|
}
|
||||||
|
|
|
||||||
126
agent/robot/trigger/clock.go
Normal file
126
agent/robot/trigger/clock.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package trigger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClockMatcher provides clock trigger matching logic
|
||||||
|
// This is extracted from Manager for reuse and testing
|
||||||
|
type ClockMatcher struct{}
|
||||||
|
|
||||||
|
// NewClockMatcher creates a new clock matcher
|
||||||
|
func NewClockMatcher() *ClockMatcher {
|
||||||
|
return &ClockMatcher{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldTrigger checks if a robot should be triggered based on its clock config
|
||||||
|
func (cm *ClockMatcher) ShouldTrigger(robot *types.Robot, now time.Time) bool {
|
||||||
|
if robot == nil || robot.Config == nil || robot.Config.Clock == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
clock := robot.Config.Clock
|
||||||
|
|
||||||
|
// Get time in robot's timezone
|
||||||
|
loc := clock.GetLocation()
|
||||||
|
localNow := now.In(loc)
|
||||||
|
|
||||||
|
switch clock.Mode {
|
||||||
|
case types.ClockTimes:
|
||||||
|
return cm.shouldTriggerTimes(robot, clock, localNow)
|
||||||
|
case types.ClockInterval:
|
||||||
|
return cm.shouldTriggerInterval(robot, clock, localNow)
|
||||||
|
case types.ClockDaemon:
|
||||||
|
return cm.shouldTriggerDaemon(robot, clock, localNow)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldTriggerTimes checks if current time matches any configured times
|
||||||
|
// times mode: run at specific times (e.g., ["09:00", "14:00", "17:00"])
|
||||||
|
func (cm *ClockMatcher) shouldTriggerTimes(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
||||||
|
// Check day of week first
|
||||||
|
if !cm.matchesDay(clock, now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current time matches any configured time
|
||||||
|
currentTime := now.Format("15:04")
|
||||||
|
for _, t := range clock.Times {
|
||||||
|
if t == currentTime {
|
||||||
|
// Check if already triggered in this minute
|
||||||
|
if !robot.LastRun.IsZero() {
|
||||||
|
lastRunInLoc := robot.LastRun.In(now.Location())
|
||||||
|
if lastRunInLoc.Format("15:04") == currentTime && lastRunInLoc.Day() == now.Day() {
|
||||||
|
return false // Already triggered this minute today
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldTriggerInterval checks if enough time has passed since last run
|
||||||
|
// interval mode: run every X duration (e.g., "30m", "2h")
|
||||||
|
func (cm *ClockMatcher) shouldTriggerInterval(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
||||||
|
interval, err := time.ParseDuration(clock.Every)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// First run if never executed
|
||||||
|
if robot.LastRun.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if interval has passed
|
||||||
|
return now.Sub(robot.LastRun) >= interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldTriggerDaemon checks if robot can restart immediately after last run
|
||||||
|
// daemon mode: restart immediately after each run completes
|
||||||
|
func (cm *ClockMatcher) shouldTriggerDaemon(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
||||||
|
// Daemon mode: trigger if not currently running
|
||||||
|
// CanRun() checks if robot has available execution slots
|
||||||
|
return robot.CanRun()
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesDay checks if current day matches the configured days
|
||||||
|
func (cm *ClockMatcher) matchesDay(clock *types.Clock, now time.Time) bool {
|
||||||
|
// Empty days or ["*"] means all days
|
||||||
|
if len(clock.Days) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, day := range clock.Days {
|
||||||
|
if day == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Match day name (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
|
||||||
|
// or full name (Monday, Tuesday, etc.)
|
||||||
|
weekday := now.Weekday().String()
|
||||||
|
shortDay := weekday[:3] // Mon, Tue, etc.
|
||||||
|
if day == weekday || day == shortDay {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTime parses a time string in "HH:MM" format
|
||||||
|
func ParseTime(timeStr string) (hour, minute int, err error) {
|
||||||
|
t, err := time.Parse("15:04", timeStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return t.Hour(), t.Minute(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTime formats hour and minute to "HH:MM" string
|
||||||
|
func FormatTime(hour, minute int) string {
|
||||||
|
return time.Date(0, 1, 1, hour, minute, 0, 0, time.UTC).Format("15:04")
|
||||||
|
}
|
||||||
426
agent/robot/trigger/clock_test.go
Normal file
426
agent/robot/trigger/clock_test.go
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
package trigger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/trigger"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== ClockMatcher Tests ====================
|
||||||
|
|
||||||
|
func TestClockMatcherShouldTrigger(t *testing.T) {
|
||||||
|
cm := trigger.NewClockMatcher()
|
||||||
|
|
||||||
|
t.Run("nil robot returns false", func(t *testing.T) {
|
||||||
|
result := cm.ShouldTrigger(nil, time.Now())
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil config returns false", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: nil,
|
||||||
|
}
|
||||||
|
result := cm.ShouldTrigger(robot, time.Now())
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil clock config returns false", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{Clock: nil},
|
||||||
|
}
|
||||||
|
result := cm.ShouldTrigger(robot, time.Now())
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Times Mode Tests ====================
|
||||||
|
|
||||||
|
func TestClockMatcherTimesMode(t *testing.T) {
|
||||||
|
cm := trigger.NewClockMatcher()
|
||||||
|
|
||||||
|
t.Run("matches configured time", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockTimes,
|
||||||
|
Times: []string{"09:00", "14:00", "17:00"},
|
||||||
|
Days: []string{"*"},
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create time at 09:00 UTC
|
||||||
|
now := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC)
|
||||||
|
result := cm.ShouldTrigger(robot, now)
|
||||||
|
assert.True(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not match non-configured time", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockTimes,
|
||||||
|
Times: []string{"09:00", "14:00", "17:00"},
|
||||||
|
Days: []string{"*"},
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create time at 10:00 UTC (not in configured times)
|
||||||
|
now := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||||
|
result := cm.ShouldTrigger(robot, now)
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("respects day filter - weekday", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockTimes,
|
||||||
|
Times: []string{"09:00"},
|
||||||
|
Days: []string{"Mon", "Tue", "Wed", "Thu", "Fri"},
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wednesday 09:00 - should trigger
|
||||||
|
wed := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC) // Wednesday
|
||||||
|
assert.True(t, cm.ShouldTrigger(robot, wed))
|
||||||
|
|
||||||
|
// Saturday 09:00 - should NOT trigger
|
||||||
|
sat := time.Date(2025, 1, 18, 9, 0, 0, 0, time.UTC) // Saturday
|
||||||
|
assert.False(t, cm.ShouldTrigger(robot, sat))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dedup - same minute same day should not trigger twice", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockTimes,
|
||||||
|
Times: []string{"09:00"},
|
||||||
|
Days: []string{"*"},
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// First trigger - should succeed
|
||||||
|
assert.True(t, cm.ShouldTrigger(robot, now))
|
||||||
|
|
||||||
|
// Simulate LastRun was set
|
||||||
|
robot.LastRun = now
|
||||||
|
|
||||||
|
// Second trigger same minute - should fail
|
||||||
|
now2 := time.Date(2025, 1, 15, 9, 0, 30, 0, time.UTC)
|
||||||
|
assert.False(t, cm.ShouldTrigger(robot, now2))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("different day should trigger again", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockTimes,
|
||||||
|
Times: []string{"09:00"},
|
||||||
|
Days: []string{"*"},
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// First day
|
||||||
|
day1 := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC)
|
||||||
|
robot.LastRun = day1
|
||||||
|
|
||||||
|
// Next day same time - should trigger
|
||||||
|
day2 := time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC)
|
||||||
|
assert.True(t, cm.ShouldTrigger(robot, day2))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Interval Mode Tests ====================
|
||||||
|
|
||||||
|
func TestClockMatcherIntervalMode(t *testing.T) {
|
||||||
|
cm := trigger.NewClockMatcher()
|
||||||
|
|
||||||
|
t.Run("first run triggers immediately", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockInterval,
|
||||||
|
Every: "30m",
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastRun is zero - should trigger
|
||||||
|
now := time.Now()
|
||||||
|
result := cm.ShouldTrigger(robot, now)
|
||||||
|
assert.True(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("triggers after interval passed", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockInterval,
|
||||||
|
Every: "30m",
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
robot.LastRun = now.Add(-31 * time.Minute) // 31 minutes ago
|
||||||
|
|
||||||
|
result := cm.ShouldTrigger(robot, now)
|
||||||
|
assert.True(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not trigger before interval", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockInterval,
|
||||||
|
Every: "30m",
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
robot.LastRun = now.Add(-15 * time.Minute) // Only 15 minutes ago
|
||||||
|
|
||||||
|
result := cm.ShouldTrigger(robot, now)
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid interval format returns false", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockInterval,
|
||||||
|
Every: "invalid",
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := cm.ShouldTrigger(robot, time.Now())
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("various interval formats", func(t *testing.T) {
|
||||||
|
intervals := []struct {
|
||||||
|
every string
|
||||||
|
lastAgo time.Duration
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"1h", 61 * time.Minute, true},
|
||||||
|
{"1h", 30 * time.Minute, false},
|
||||||
|
{"2h", 121 * time.Minute, true},
|
||||||
|
{"2h", 60 * time.Minute, false},
|
||||||
|
{"10s", 11 * time.Second, true},
|
||||||
|
{"10s", 5 * time.Second, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range intervals {
|
||||||
|
t.Run(tt.every, func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockInterval,
|
||||||
|
Every: tt.every,
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
robot.LastRun = now.Add(-tt.lastAgo)
|
||||||
|
|
||||||
|
result := cm.ShouldTrigger(robot, now)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Daemon Mode Tests ====================
|
||||||
|
|
||||||
|
func TestClockMatcherDaemonMode(t *testing.T) {
|
||||||
|
cm := trigger.NewClockMatcher()
|
||||||
|
|
||||||
|
t.Run("triggers when robot can run", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockDaemon,
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
Quota: &types.Quota{Max: 2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// No running executions - should trigger
|
||||||
|
result := cm.ShouldTrigger(robot, time.Now())
|
||||||
|
assert.True(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not trigger when at quota", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockDaemon,
|
||||||
|
TZ: "UTC",
|
||||||
|
},
|
||||||
|
Quota: &types.Quota{Max: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add one execution to fill quota
|
||||||
|
exec := &types.Execution{ID: "exec_001"}
|
||||||
|
robot.AddExecution(exec)
|
||||||
|
|
||||||
|
result := cm.ShouldTrigger(robot, time.Now())
|
||||||
|
assert.False(t, result)
|
||||||
|
|
||||||
|
// Remove execution
|
||||||
|
robot.RemoveExecution("exec_001")
|
||||||
|
|
||||||
|
// Now should trigger
|
||||||
|
result = cm.ShouldTrigger(robot, time.Now())
|
||||||
|
assert.True(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Timezone Tests ====================
|
||||||
|
|
||||||
|
func TestClockMatcherTimezone(t *testing.T) {
|
||||||
|
cm := trigger.NewClockMatcher()
|
||||||
|
|
||||||
|
t.Run("respects timezone for times mode", func(t *testing.T) {
|
||||||
|
// Robot configured for Asia/Shanghai (UTC+8)
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockTimes,
|
||||||
|
Times: []string{"09:00"},
|
||||||
|
Days: []string{"*"},
|
||||||
|
TZ: "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 01:00 UTC = 09:00 Shanghai - should trigger
|
||||||
|
utc0100 := time.Date(2025, 1, 15, 1, 0, 0, 0, time.UTC)
|
||||||
|
assert.True(t, cm.ShouldTrigger(robot, utc0100))
|
||||||
|
|
||||||
|
// 09:00 UTC = 17:00 Shanghai - should NOT trigger
|
||||||
|
utc0900 := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC)
|
||||||
|
assert.False(t, cm.ShouldTrigger(robot, utc0900))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid timezone falls back to local", func(t *testing.T) {
|
||||||
|
robot := &types.Robot{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Config: &types.Config{
|
||||||
|
Clock: &types.Clock{
|
||||||
|
Mode: types.ClockTimes,
|
||||||
|
Times: []string{"09:00"},
|
||||||
|
Days: []string{"*"},
|
||||||
|
TZ: "Invalid/Timezone",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still work with local time
|
||||||
|
local0900 := time.Date(2025, 1, 15, 9, 0, 0, 0, time.Local)
|
||||||
|
result := cm.ShouldTrigger(robot, local0900)
|
||||||
|
// Result depends on local timezone, just verify no panic
|
||||||
|
assert.IsType(t, true, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ParseTime/FormatTime Tests ====================
|
||||||
|
|
||||||
|
func TestParseTime(t *testing.T) {
|
||||||
|
t.Run("parses valid time", func(t *testing.T) {
|
||||||
|
hour, minute, err := trigger.ParseTime("09:30")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 9, hour)
|
||||||
|
assert.Equal(t, 30, minute)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("parses midnight", func(t *testing.T) {
|
||||||
|
hour, minute, err := trigger.ParseTime("00:00")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, hour)
|
||||||
|
assert.Equal(t, 0, minute)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("parses 23:59", func(t *testing.T) {
|
||||||
|
hour, minute, err := trigger.ParseTime("23:59")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 23, hour)
|
||||||
|
assert.Equal(t, 59, minute)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid format returns error", func(t *testing.T) {
|
||||||
|
// Note: time.Parse("15:04", "9:30") actually succeeds
|
||||||
|
// Only truly invalid formats fail
|
||||||
|
|
||||||
|
_, _, err := trigger.ParseTime("09:30:00")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
_, _, err = trigger.ParseTime("invalid")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
_, _, err = trigger.ParseTime("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTime(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
hour int
|
||||||
|
minute int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{9, 0, "09:00"},
|
||||||
|
{9, 30, "09:30"},
|
||||||
|
{0, 0, "00:00"},
|
||||||
|
{23, 59, "23:59"},
|
||||||
|
{14, 5, "14:05"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.expected, func(t *testing.T) {
|
||||||
|
result := trigger.FormatTime(tt.hour, tt.minute)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
248
agent/robot/trigger/control.go
Normal file
248
agent/robot/trigger/control.go
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
package trigger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecutionController manages execution lifecycle (pause/resume/stop)
|
||||||
|
type ExecutionController struct {
|
||||||
|
executions map[string]*ControlledExecution
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlledExecution represents an execution that can be controlled
|
||||||
|
type ControlledExecution struct {
|
||||||
|
ID string
|
||||||
|
MemberID string
|
||||||
|
TeamID string
|
||||||
|
Status types.ExecStatus
|
||||||
|
Phase types.Phase
|
||||||
|
StartTime time.Time
|
||||||
|
PausedAt *time.Time
|
||||||
|
|
||||||
|
// Control channels
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
paused bool
|
||||||
|
pauseMu sync.Mutex
|
||||||
|
resumeCh chan struct{} // signaled (closed) when resumed
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutionController creates a new execution controller
|
||||||
|
func NewExecutionController() *ExecutionController {
|
||||||
|
return &ExecutionController{
|
||||||
|
executions: make(map[string]*ControlledExecution),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track starts tracking an execution
|
||||||
|
func (c *ExecutionController) Track(execID, memberID, teamID string) *ControlledExecution {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
exec := &ControlledExecution{
|
||||||
|
ID: execID,
|
||||||
|
MemberID: memberID,
|
||||||
|
TeamID: teamID,
|
||||||
|
Status: types.ExecRunning,
|
||||||
|
Phase: types.PhaseInspiration,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
paused: false,
|
||||||
|
resumeCh: nil, // nil when not paused, created on pause
|
||||||
|
}
|
||||||
|
|
||||||
|
c.executions[execID] = exec
|
||||||
|
return exec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untrack stops tracking an execution
|
||||||
|
func (c *ExecutionController) Untrack(execID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.executions, execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a tracked execution
|
||||||
|
func (c *ExecutionController) Get(execID string) *ControlledExecution {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.executions[execID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all tracked executions
|
||||||
|
func (c *ExecutionController) List() []*ControlledExecution {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*ControlledExecution, 0, len(c.executions))
|
||||||
|
for _, exec := range c.executions {
|
||||||
|
result = append(result, exec)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByMember returns all executions for a specific member
|
||||||
|
func (c *ExecutionController) ListByMember(memberID string) []*ControlledExecution {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
var result []*ControlledExecution
|
||||||
|
for _, exec := range c.executions {
|
||||||
|
if exec.MemberID == memberID {
|
||||||
|
result = append(result, exec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause pauses an execution
|
||||||
|
func (c *ExecutionController) Pause(execID string) error {
|
||||||
|
exec := c.Get(execID)
|
||||||
|
if exec == nil {
|
||||||
|
return fmt.Errorf("execution not found: %s", execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
exec.pauseMu.Lock()
|
||||||
|
defer exec.pauseMu.Unlock()
|
||||||
|
|
||||||
|
if exec.paused {
|
||||||
|
return fmt.Errorf("execution already paused: %s", execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
exec.paused = true
|
||||||
|
now := time.Now()
|
||||||
|
exec.PausedAt = &now
|
||||||
|
|
||||||
|
// Create a new resume channel that will be closed on resume
|
||||||
|
exec.resumeCh = make(chan struct{})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume resumes a paused execution
|
||||||
|
func (c *ExecutionController) Resume(execID string) error {
|
||||||
|
exec := c.Get(execID)
|
||||||
|
if exec == nil {
|
||||||
|
return fmt.Errorf("execution not found: %s", execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
exec.pauseMu.Lock()
|
||||||
|
defer exec.pauseMu.Unlock()
|
||||||
|
|
||||||
|
if !exec.paused {
|
||||||
|
return fmt.Errorf("execution not paused: %s", execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
exec.paused = false
|
||||||
|
exec.PausedAt = nil
|
||||||
|
|
||||||
|
// Close the resume channel to signal resume to waiting goroutines
|
||||||
|
if exec.resumeCh != nil {
|
||||||
|
close(exec.resumeCh)
|
||||||
|
exec.resumeCh = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops an execution
|
||||||
|
func (c *ExecutionController) Stop(execID string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
exec, ok := c.executions[execID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("execution not found: %s", execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the context to signal stop
|
||||||
|
if exec.cancel != nil {
|
||||||
|
exec.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
exec.Status = types.ExecCancelled
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
delete(c.executions, execID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ControlledExecution methods ====================
|
||||||
|
|
||||||
|
// IsPaused returns true if the execution is paused
|
||||||
|
func (e *ControlledExecution) IsPaused() bool {
|
||||||
|
e.pauseMu.Lock()
|
||||||
|
defer e.pauseMu.Unlock()
|
||||||
|
return e.paused
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCancelled returns true if the execution is cancelled
|
||||||
|
func (e *ControlledExecution) IsCancelled() bool {
|
||||||
|
select {
|
||||||
|
case <-e.ctx.Done():
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context returns the execution's context
|
||||||
|
func (e *ControlledExecution) Context() context.Context {
|
||||||
|
return e.ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitIfPaused blocks until the execution is resumed or cancelled
|
||||||
|
// Returns error if cancelled
|
||||||
|
func (e *ControlledExecution) WaitIfPaused() error {
|
||||||
|
e.pauseMu.Lock()
|
||||||
|
paused := e.paused
|
||||||
|
resumeCh := e.resumeCh
|
||||||
|
e.pauseMu.Unlock()
|
||||||
|
|
||||||
|
if !paused {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety check: if paused but resumeCh is nil (shouldn't happen in normal flow),
|
||||||
|
// treat as not paused to avoid blocking forever on nil channel
|
||||||
|
if resumeCh == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resumeCh is created when paused and closed when resumed
|
||||||
|
// Wait for resume signal or cancellation
|
||||||
|
select {
|
||||||
|
case <-e.ctx.Done():
|
||||||
|
return types.ErrExecutionCancelled
|
||||||
|
case <-resumeCh:
|
||||||
|
// Resume signal received, execution can continue
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCancelled checks if the execution is cancelled and returns error if so
|
||||||
|
func (e *ControlledExecution) CheckCancelled() error {
|
||||||
|
if e.IsCancelled() {
|
||||||
|
return types.ErrExecutionCancelled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePhase updates the current phase
|
||||||
|
func (e *ControlledExecution) UpdatePhase(phase types.Phase) {
|
||||||
|
e.Phase = phase
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates the execution status
|
||||||
|
func (e *ControlledExecution) UpdateStatus(status types.ExecStatus) {
|
||||||
|
e.Status = status
|
||||||
|
}
|
||||||
464
agent/robot/trigger/control_test.go
Normal file
464
agent/robot/trigger/control_test.go
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
package trigger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/trigger"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== ExecutionController Tests ====================
|
||||||
|
|
||||||
|
func TestExecutionControllerTrack(t *testing.T) {
|
||||||
|
t.Run("tracks new execution", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
assert.NotNil(t, exec)
|
||||||
|
assert.Equal(t, "exec_001", exec.ID)
|
||||||
|
assert.Equal(t, "robot_001", exec.MemberID)
|
||||||
|
assert.Equal(t, "team_001", exec.TeamID)
|
||||||
|
assert.Equal(t, types.ExecRunning, exec.Status)
|
||||||
|
assert.False(t, exec.IsPaused())
|
||||||
|
assert.False(t, exec.IsCancelled())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get tracked execution", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
exec := ctrl.Get("exec_001")
|
||||||
|
assert.NotNil(t, exec)
|
||||||
|
assert.Equal(t, "exec_001", exec.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get non-existent execution returns nil", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
|
||||||
|
exec := ctrl.Get("non_existent")
|
||||||
|
assert.Nil(t, exec)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionControllerList(t *testing.T) {
|
||||||
|
t.Run("list all executions", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
ctrl.Track("exec_002", "robot_002", "team_001")
|
||||||
|
ctrl.Track("exec_003", "robot_001", "team_002")
|
||||||
|
|
||||||
|
list := ctrl.List()
|
||||||
|
assert.Len(t, list, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list by member", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
ctrl.Track("exec_002", "robot_002", "team_001")
|
||||||
|
ctrl.Track("exec_003", "robot_001", "team_002")
|
||||||
|
|
||||||
|
list := ctrl.ListByMember("robot_001")
|
||||||
|
assert.Len(t, list, 2)
|
||||||
|
|
||||||
|
list = ctrl.ListByMember("robot_002")
|
||||||
|
assert.Len(t, list, 1)
|
||||||
|
|
||||||
|
list = ctrl.ListByMember("robot_003")
|
||||||
|
assert.Len(t, list, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionControllerUntrack(t *testing.T) {
|
||||||
|
t.Run("untrack removes execution", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
assert.NotNil(t, ctrl.Get("exec_001"))
|
||||||
|
|
||||||
|
ctrl.Untrack("exec_001")
|
||||||
|
|
||||||
|
assert.Nil(t, ctrl.Get("exec_001"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("untrack non-existent does not panic", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
ctrl.Untrack("non_existent")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Pause/Resume Tests ====================
|
||||||
|
|
||||||
|
func TestExecutionControllerPause(t *testing.T) {
|
||||||
|
t.Run("pause execution", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
err := ctrl.Pause("exec_001")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, exec.IsPaused())
|
||||||
|
assert.NotNil(t, exec.PausedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pause non-existent returns error", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
|
||||||
|
err := ctrl.Pause("non_existent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pause already paused returns error", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
err := ctrl.Pause("exec_001")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = ctrl.Pause("exec_001")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already paused")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionControllerResume(t *testing.T) {
|
||||||
|
t.Run("resume paused execution", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
ctrl.Pause("exec_001")
|
||||||
|
assert.True(t, exec.IsPaused())
|
||||||
|
|
||||||
|
err := ctrl.Resume("exec_001")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, exec.IsPaused())
|
||||||
|
assert.Nil(t, exec.PausedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("resume non-existent returns error", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
|
||||||
|
err := ctrl.Resume("non_existent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("resume not paused returns error", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
err := ctrl.Resume("exec_001")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not paused")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Stop Tests ====================
|
||||||
|
|
||||||
|
func TestExecutionControllerStop(t *testing.T) {
|
||||||
|
t.Run("stop execution", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
err := ctrl.Stop("exec_001")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, exec.IsCancelled())
|
||||||
|
assert.Equal(t, types.ExecCancelled, exec.Status)
|
||||||
|
|
||||||
|
// Should be removed from tracking
|
||||||
|
assert.Nil(t, ctrl.Get("exec_001"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stop non-existent returns error", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
|
||||||
|
err := ctrl.Stop("non_existent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ControlledExecution Methods Tests ====================
|
||||||
|
|
||||||
|
func TestControlledExecutionContext(t *testing.T) {
|
||||||
|
t.Run("context is valid", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
ctx := exec.Context()
|
||||||
|
assert.NotNil(t, ctx)
|
||||||
|
|
||||||
|
// Context should not be done yet
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("context should not be done")
|
||||||
|
default:
|
||||||
|
// OK
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("context done after stop", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
ctx := exec.Context()
|
||||||
|
ctrl.Stop("exec_001")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// OK
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("context should be done after stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlledExecutionCheckCancelled(t *testing.T) {
|
||||||
|
t.Run("not cancelled returns nil", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
err := exec.CheckCancelled()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cancelled returns error", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
ctrl.Stop("exec_001")
|
||||||
|
|
||||||
|
err := exec.CheckCancelled()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, types.ErrExecutionCancelled, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlledExecutionUpdatePhase(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
assert.Equal(t, types.PhaseInspiration, exec.Phase)
|
||||||
|
|
||||||
|
exec.UpdatePhase(types.PhaseGoals)
|
||||||
|
assert.Equal(t, types.PhaseGoals, exec.Phase)
|
||||||
|
|
||||||
|
exec.UpdatePhase(types.PhaseTasks)
|
||||||
|
assert.Equal(t, types.PhaseTasks, exec.Phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlledExecutionUpdateStatus(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
assert.Equal(t, types.ExecRunning, exec.Status)
|
||||||
|
|
||||||
|
exec.UpdateStatus(types.ExecCompleted)
|
||||||
|
assert.Equal(t, types.ExecCompleted, exec.Status)
|
||||||
|
|
||||||
|
exec.UpdateStatus(types.ExecFailed)
|
||||||
|
assert.Equal(t, types.ExecFailed, exec.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WaitIfPaused Tests ====================
|
||||||
|
|
||||||
|
func TestControlledExecutionWaitIfPaused(t *testing.T) {
|
||||||
|
t.Run("returns immediately if not paused", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
done := make(chan error)
|
||||||
|
go func() {
|
||||||
|
done <- exec.WaitIfPaused()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("WaitIfPaused should return immediately when not paused")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not infinite loop when paused without resume", func(t *testing.T) {
|
||||||
|
// This test verifies the fix for the infinite loop bug
|
||||||
|
// where WaitIfPaused would spin if pauseCh was closed but paused remained true
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
ctrl.Pause("exec_001")
|
||||||
|
|
||||||
|
// Start WaitIfPaused in a goroutine
|
||||||
|
done := make(chan error)
|
||||||
|
go func() {
|
||||||
|
done <- exec.WaitIfPaused()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait a bit - if there's an infinite loop, CPU would spike
|
||||||
|
// The goroutine should be blocked, not spinning
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Now stop the execution - this should unblock WaitIfPaused
|
||||||
|
ctrl.Stop("exec_001")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
// Should get cancellation error
|
||||||
|
assert.Error(t, err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
t.Fatal("WaitIfPaused should unblock after stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rapid pause-resume-pause does not cause issues", func(t *testing.T) {
|
||||||
|
// Test TOCTOU race condition handling
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
// Pause first
|
||||||
|
ctrl.Pause("exec_001")
|
||||||
|
|
||||||
|
done := make(chan error)
|
||||||
|
go func() {
|
||||||
|
done <- exec.WaitIfPaused()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Rapid resume then pause again
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
ctrl.Resume("exec_001")
|
||||||
|
|
||||||
|
// WaitIfPaused should return (the original resumeCh was closed)
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
t.Fatal("WaitIfPaused should return after resume")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blocks when paused, resumes after resume", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
ctrl.Pause("exec_001")
|
||||||
|
|
||||||
|
done := make(chan error)
|
||||||
|
go func() {
|
||||||
|
done <- exec.WaitIfPaused()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Should be blocked
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("WaitIfPaused should block when paused")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// OK, still blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume
|
||||||
|
ctrl.Resume("exec_001")
|
||||||
|
|
||||||
|
// Should unblock
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("WaitIfPaused should unblock after resume")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error when cancelled while paused", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
exec := ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
ctrl.Pause("exec_001")
|
||||||
|
|
||||||
|
done := make(chan error)
|
||||||
|
go func() {
|
||||||
|
done <- exec.WaitIfPaused()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Should be blocked
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("WaitIfPaused should block when paused")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// OK, still blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop instead of resume
|
||||||
|
ctrl.Stop("exec_001")
|
||||||
|
|
||||||
|
// Should unblock with error
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, types.ErrExecutionCancelled, err)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("WaitIfPaused should unblock after stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Concurrent Access Tests ====================
|
||||||
|
|
||||||
|
func TestExecutionControllerConcurrency(t *testing.T) {
|
||||||
|
t.Run("concurrent track and list", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Concurrent tracking
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
ctrl.Track(
|
||||||
|
"exec_"+string(rune('0'+id%10)),
|
||||||
|
"robot_"+string(rune('0'+id%5)),
|
||||||
|
"team_001",
|
||||||
|
)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent listing
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = ctrl.List()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
// No race conditions or panics
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("concurrent pause/resume", func(t *testing.T) {
|
||||||
|
ctrl := trigger.NewExecutionController()
|
||||||
|
ctrl.Track("exec_001", "robot_001", "team_001")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Concurrent pause/resume attempts
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = ctrl.Pause("exec_001")
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = ctrl.Resume("exec_001")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
// No race conditions or panics
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,48 +1,142 @@
|
||||||
|
// Package trigger provides trigger-related utilities and execution control
|
||||||
|
// The main trigger logic is in the manager package.
|
||||||
|
// This package provides:
|
||||||
|
// - Validation functions for intervention and event requests
|
||||||
|
// - Builder helpers for TriggerInput
|
||||||
|
// - ExecutionController for pause/resume/stop
|
||||||
|
// - ClockMatcher for clock trigger matching (reusable)
|
||||||
package trigger
|
package trigger
|
||||||
|
|
||||||
import "github.com/yaoapp/yao/agent/robot/types"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
// Trigger handles all trigger sources
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
// This is a stub implementation for Phase 2
|
)
|
||||||
type Trigger struct{}
|
|
||||||
|
|
||||||
// New creates a new trigger instance
|
// ValidateIntervention validates a human intervention request
|
||||||
func New() *Trigger {
|
func ValidateIntervention(req *types.InterveneRequest) error {
|
||||||
return &Trigger{}
|
if req == nil {
|
||||||
|
return fmt.Errorf("request is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MemberID == "" {
|
||||||
|
return fmt.Errorf("member_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidAction(req.Action) {
|
||||||
|
return fmt.Errorf("invalid action: %s", req.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate action-specific requirements
|
||||||
|
switch req.Action {
|
||||||
|
case types.ActionTaskAdd, types.ActionGoalAdd, types.ActionInstruct:
|
||||||
|
// These actions require messages
|
||||||
|
if len(req.Messages) == 0 {
|
||||||
|
return fmt.Errorf("messages required for action: %s", req.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
case types.ActionPlanAdd:
|
||||||
|
// Plan add requires plan_time
|
||||||
|
if req.PlanTime == nil {
|
||||||
|
return fmt.Errorf("plan_time required for action: plan.add")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intervene processes human intervention
|
// ValidateEvent validates an event trigger request
|
||||||
// Stub: returns empty result (will be implemented in Phase 3)
|
func ValidateEvent(req *types.EventRequest) error {
|
||||||
func (t *Trigger) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
|
if req == nil {
|
||||||
return &types.ExecutionResult{}, nil
|
return fmt.Errorf("request is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event processes event trigger
|
if req.MemberID == "" {
|
||||||
// Stub: returns empty result (will be implemented in Phase 3)
|
return fmt.Errorf("member_id is required")
|
||||||
func (t *Trigger) Event(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
|
}
|
||||||
return &types.ExecutionResult{}, nil
|
|
||||||
|
if req.Source == "" {
|
||||||
|
return fmt.Errorf("source is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.EventType == "" {
|
||||||
|
return fmt.Errorf("event_type is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume resumes a paused execution
|
// BuildEventInput creates a TriggerInput from an event request
|
||||||
// Stub: returns nil (will be implemented in Phase 3)
|
func BuildEventInput(req *types.EventRequest) *types.TriggerInput {
|
||||||
func (t *Trigger) Resume(ctx *types.Context, execID string) error {
|
return &types.TriggerInput{
|
||||||
return nil
|
Source: types.EventSource(req.Source),
|
||||||
|
EventType: req.EventType,
|
||||||
|
Data: req.Data,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops a running execution
|
// isValidAction checks if the intervention action is valid
|
||||||
// Stub: returns nil (will be implemented in Phase 3)
|
func isValidAction(action types.InterventionAction) bool {
|
||||||
func (t *Trigger) Stop(ctx *types.Context, execID string) error {
|
switch action {
|
||||||
return nil
|
case types.ActionTaskAdd,
|
||||||
|
types.ActionTaskCancel,
|
||||||
|
types.ActionTaskUpdate,
|
||||||
|
types.ActionGoalAdjust,
|
||||||
|
types.ActionGoalAdd,
|
||||||
|
types.ActionGoalComplete,
|
||||||
|
types.ActionGoalCancel,
|
||||||
|
types.ActionPlanAdd,
|
||||||
|
types.ActionPlanRemove,
|
||||||
|
types.ActionPlanUpdate,
|
||||||
|
types.ActionInstruct:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActionCategory returns the category of an intervention action
|
||||||
|
func GetActionCategory(action types.InterventionAction) string {
|
||||||
|
switch action {
|
||||||
|
case types.ActionTaskAdd, types.ActionTaskCancel, types.ActionTaskUpdate:
|
||||||
|
return "task"
|
||||||
|
case types.ActionGoalAdjust, types.ActionGoalAdd, types.ActionGoalComplete, types.ActionGoalCancel:
|
||||||
|
return "goal"
|
||||||
|
case types.ActionPlanAdd, types.ActionPlanRemove, types.ActionPlanUpdate:
|
||||||
|
return "plan"
|
||||||
|
case types.ActionInstruct:
|
||||||
|
return "instruct"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActionDescription returns a human-readable description of an action
|
||||||
|
func GetActionDescription(action types.InterventionAction) string {
|
||||||
|
switch action {
|
||||||
|
case types.ActionTaskAdd:
|
||||||
|
return "Add a new task"
|
||||||
|
case types.ActionTaskCancel:
|
||||||
|
return "Cancel a task"
|
||||||
|
case types.ActionTaskUpdate:
|
||||||
|
return "Update task details"
|
||||||
|
case types.ActionGoalAdjust:
|
||||||
|
return "Adjust current goal"
|
||||||
|
case types.ActionGoalAdd:
|
||||||
|
return "Add a new goal"
|
||||||
|
case types.ActionGoalComplete:
|
||||||
|
return "Mark goal as complete"
|
||||||
|
case types.ActionGoalCancel:
|
||||||
|
return "Cancel a goal"
|
||||||
|
case types.ActionPlanAdd:
|
||||||
|
return "Add to plan queue"
|
||||||
|
case types.ActionPlanRemove:
|
||||||
|
return "Remove from plan queue"
|
||||||
|
case types.ActionPlanUpdate:
|
||||||
|
return "Update planned item"
|
||||||
|
case types.ActionInstruct:
|
||||||
|
return "Direct instruction to robot"
|
||||||
|
default:
|
||||||
|
return "Unknown action"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
276
agent/robot/trigger/trigger_test.go
Normal file
276
agent/robot/trigger/trigger_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
package trigger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/trigger"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== ValidateIntervention Tests ====================
|
||||||
|
|
||||||
|
func TestValidateIntervention(t *testing.T) {
|
||||||
|
t.Run("nil request returns error", func(t *testing.T) {
|
||||||
|
err := trigger.ValidateIntervention(nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "request is nil")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty member_id returns error", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "",
|
||||||
|
Action: types.ActionTaskAdd,
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "member_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid action returns error", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.InterventionAction("invalid.action"),
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid action")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("task.add without messages returns error", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.ActionTaskAdd,
|
||||||
|
Messages: nil,
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "messages required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("goal.add without messages returns error", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.ActionGoalAdd,
|
||||||
|
Messages: nil,
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "messages required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("instruct without messages returns error", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.ActionInstruct,
|
||||||
|
Messages: nil,
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "messages required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("plan.add without plan_time returns error", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.ActionPlanAdd,
|
||||||
|
PlanTime: nil,
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "plan_time required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid task.add request passes", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.ActionTaskAdd,
|
||||||
|
Messages: []agentcontext.Message{
|
||||||
|
{Role: agentcontext.RoleUser, Content: "Add a new task"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid plan.add request passes", func(t *testing.T) {
|
||||||
|
planTime := time.Now().Add(time.Hour)
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.ActionPlanAdd,
|
||||||
|
PlanTime: &planTime,
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("task.cancel without messages passes", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.ActionTaskCancel,
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("goal.adjust without messages passes", func(t *testing.T) {
|
||||||
|
req := &types.InterveneRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Action: types.ActionGoalAdjust,
|
||||||
|
}
|
||||||
|
err := trigger.ValidateIntervention(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ValidateEvent Tests ====================
|
||||||
|
|
||||||
|
func TestValidateEvent(t *testing.T) {
|
||||||
|
t.Run("nil request returns error", func(t *testing.T) {
|
||||||
|
err := trigger.ValidateEvent(nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "request is nil")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty member_id returns error", func(t *testing.T) {
|
||||||
|
req := &types.EventRequest{
|
||||||
|
MemberID: "",
|
||||||
|
Source: "webhook",
|
||||||
|
EventType: "lead.created",
|
||||||
|
}
|
||||||
|
err := trigger.ValidateEvent(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "member_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty source returns error", func(t *testing.T) {
|
||||||
|
req := &types.EventRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Source: "",
|
||||||
|
EventType: "lead.created",
|
||||||
|
}
|
||||||
|
err := trigger.ValidateEvent(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "source is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty event_type returns error", func(t *testing.T) {
|
||||||
|
req := &types.EventRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Source: "webhook",
|
||||||
|
EventType: "",
|
||||||
|
}
|
||||||
|
err := trigger.ValidateEvent(req)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "event_type is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid request passes", func(t *testing.T) {
|
||||||
|
req := &types.EventRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Source: "webhook",
|
||||||
|
EventType: "lead.created",
|
||||||
|
Data: map[string]interface{}{"name": "John"},
|
||||||
|
}
|
||||||
|
err := trigger.ValidateEvent(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BuildEventInput Tests ====================
|
||||||
|
|
||||||
|
func TestBuildEventInput(t *testing.T) {
|
||||||
|
t.Run("builds correct TriggerInput", func(t *testing.T) {
|
||||||
|
req := &types.EventRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Source: "webhook",
|
||||||
|
EventType: "lead.created",
|
||||||
|
Data: map[string]interface{}{"name": "John", "email": "john@example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := trigger.BuildEventInput(req)
|
||||||
|
|
||||||
|
assert.NotNil(t, input)
|
||||||
|
assert.Equal(t, types.EventSource("webhook"), input.Source)
|
||||||
|
assert.Equal(t, "lead.created", input.EventType)
|
||||||
|
assert.Equal(t, "John", input.Data["name"])
|
||||||
|
assert.Equal(t, "john@example.com", input.Data["email"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles nil data", func(t *testing.T) {
|
||||||
|
req := &types.EventRequest{
|
||||||
|
MemberID: "robot_001",
|
||||||
|
Source: "database",
|
||||||
|
EventType: "order.paid",
|
||||||
|
Data: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
input := trigger.BuildEventInput(req)
|
||||||
|
|
||||||
|
assert.NotNil(t, input)
|
||||||
|
assert.Equal(t, types.EventSource("database"), input.Source)
|
||||||
|
assert.Equal(t, "order.paid", input.EventType)
|
||||||
|
assert.Nil(t, input.Data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GetActionCategory Tests ====================
|
||||||
|
|
||||||
|
func TestGetActionCategory(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
action types.InterventionAction
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{types.ActionTaskAdd, "task"},
|
||||||
|
{types.ActionTaskCancel, "task"},
|
||||||
|
{types.ActionTaskUpdate, "task"},
|
||||||
|
{types.ActionGoalAdjust, "goal"},
|
||||||
|
{types.ActionGoalAdd, "goal"},
|
||||||
|
{types.ActionGoalComplete, "goal"},
|
||||||
|
{types.ActionGoalCancel, "goal"},
|
||||||
|
{types.ActionPlanAdd, "plan"},
|
||||||
|
{types.ActionPlanRemove, "plan"},
|
||||||
|
{types.ActionPlanUpdate, "plan"},
|
||||||
|
{types.ActionInstruct, "instruct"},
|
||||||
|
{types.InterventionAction("unknown"), "unknown"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.action), func(t *testing.T) {
|
||||||
|
result := trigger.GetActionCategory(tt.action)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GetActionDescription Tests ====================
|
||||||
|
|
||||||
|
func TestGetActionDescription(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
action types.InterventionAction
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{types.ActionTaskAdd, "Add"},
|
||||||
|
{types.ActionTaskCancel, "Cancel"},
|
||||||
|
{types.ActionTaskUpdate, "Update"},
|
||||||
|
{types.ActionGoalAdjust, "Adjust"},
|
||||||
|
{types.ActionGoalAdd, "Add"},
|
||||||
|
{types.ActionGoalComplete, "complete"},
|
||||||
|
{types.ActionGoalCancel, "Cancel"},
|
||||||
|
{types.ActionPlanAdd, "plan"},
|
||||||
|
{types.ActionPlanRemove, "Remove"},
|
||||||
|
{types.ActionPlanUpdate, "Update"},
|
||||||
|
{types.ActionInstruct, "instruction"},
|
||||||
|
{types.InterventionAction("unknown"), "Unknown"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.action), func(t *testing.T) {
|
||||||
|
result := trigger.GetActionDescription(tt.action)
|
||||||
|
assert.NotEmpty(t, result)
|
||||||
|
assert.Contains(t, result, tt.contains)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue