Merge pull request #1423 from trheyi/main
Refactor Delivery Structures and Enhance Documentation
This commit is contained in:
commit
46a6bc1177
33 changed files with 6065 additions and 337 deletions
|
|
@ -138,6 +138,7 @@ Uses existing `__yao.member` model (`yao/models/member.mod.yao`):
|
||||||
| `robot_config` | JSON | Agent configuration (see section 5) |
|
| `robot_config` | JSON | Agent configuration (see section 5) |
|
||||||
| `robot_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` |
|
| `robot_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` |
|
||||||
| `system_prompt` | text | Identity & role prompt |
|
| `system_prompt` | text | Identity & role prompt |
|
||||||
|
| `robot_email` | string | Robot's email address for sending emails (From address) |
|
||||||
| `agents` | JSON | Accessible agents list |
|
| `agents` | JSON | Accessible agents list |
|
||||||
| `mcp_servers` | JSON | Accessible MCP servers |
|
| `mcp_servers` | JSON | Accessible MCP servers |
|
||||||
| `manager_id` | string | Direct manager user ID |
|
| `manager_id` | string | Direct manager user ID |
|
||||||
|
|
@ -262,7 +263,7 @@ Human/Event: P1 → P2 → P3 → P4 → P5
|
||||||
| P1 | Goal Gen | Report + history | Goals | Always |
|
| P1 | Goal Gen | Report + history | Goals | Always |
|
||||||
| P2 | Task Plan | Goals + tools | Tasks | Always |
|
| P2 | Task Plan | Goals + tools | Tasks | Always |
|
||||||
| P3 | Run + Valid | Tasks + Experts | TaskResults | Always |
|
| P3 | Run + Valid | Tasks + Experts | TaskResults | Always |
|
||||||
| P4 | Delivery | All results | Email/File | Always |
|
| P4 | Delivery | All results | Email/Webhook/Process | Always |
|
||||||
| P5 | Learning | Summary | KB entries | Always |
|
| P5 | Learning | Summary | KB entries | Always |
|
||||||
|
|
||||||
### 4.2 P0: Inspiration (Clock only)
|
### 4.2 P0: Inspiration (Clock only)
|
||||||
|
|
@ -316,7 +317,7 @@ type Goals struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryTarget struct {
|
type DeliveryTarget struct {
|
||||||
Type DeliveryType // email | webhook | report | notification
|
Type DeliveryType // Preferred delivery type (P4 will use Delivery Center)
|
||||||
Recipients []string // email addresses, webhook URLs, user IDs
|
Recipients []string // email addresses, webhook URLs, user IDs
|
||||||
Format string // markdown | html | json | text
|
Format string // markdown | html | json | text
|
||||||
Template string // template name
|
Template string // template name
|
||||||
|
|
@ -489,15 +490,197 @@ Universal assertion library supporting 8 types:
|
||||||
|
|
||||||
### 4.6 P4: Deliver
|
### 4.6 P4: Deliver
|
||||||
|
|
||||||
Send output:
|
P4 generates delivery content and pushes to Delivery Center. **Agent only generates content, Delivery Center decides channels.**
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ P4 Delivery Agent │
|
||||||
|
│ Role: Generate content only (Summary, Body, Attachments) │
|
||||||
|
│ NOT responsible for: Channel selection │
|
||||||
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ DeliveryRequest │
|
||||||
|
│ - Content: Summary, Body, Attachments │
|
||||||
|
│ - Context: member_id, execution_id, trigger, team │
|
||||||
|
│ (No Channels - Delivery Center decides) │
|
||||||
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Delivery Center │
|
||||||
|
│ Role: │
|
||||||
|
│ 1. Read Robot/User delivery preferences │
|
||||||
|
│ 2. Decide which channels to use │
|
||||||
|
│ 3. Execute delivery (email, webhook, process) │
|
||||||
|
│ 4. Future: auto-notify based on user subscriptions │
|
||||||
|
│ │
|
||||||
|
│ (Current: internal, future: yao/delivery) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Design:**
|
||||||
|
- **Separation of concerns**: Agent generates content, Delivery Center handles channels
|
||||||
|
- **User preferences**: Channels decided by Robot/User configuration, not Agent
|
||||||
|
- **Automatic delivery**: If webhook configured, every execution pushes automatically
|
||||||
|
- **Future-ready**: Delivery Center can be extracted to `yao/delivery` package
|
||||||
|
|
||||||
|
**Delivery Request Structure:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DeliveryRequest - pushed to Delivery Center
|
||||||
|
// No Channels field - Delivery Center decides based on preferences
|
||||||
|
type DeliveryRequest struct {
|
||||||
|
Content *DeliveryContent `json:"content"` // Agent-generated content
|
||||||
|
Context *DeliveryContext `json:"context"` // Tracking info
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContent - content generated by Delivery Agent
|
||||||
|
type DeliveryContent struct {
|
||||||
|
Summary string `json:"summary"` // Brief 1-2 sentence summary
|
||||||
|
Body string `json:"body"` // Full markdown report
|
||||||
|
Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryAttachment - task output attachment with metadata
|
||||||
|
type DeliveryAttachment struct {
|
||||||
|
Title string `json:"title"` // Human-readable title
|
||||||
|
Description string `json:"description,omitempty"` // What this artifact is
|
||||||
|
TaskID string `json:"task_id,omitempty"` // Which task produced this
|
||||||
|
File string `json:"file"` // Wrapper: __<uploader>://<fileID>
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContext - tracking and audit info
|
||||||
|
type DeliveryContext struct {
|
||||||
|
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||||
|
ExecutionID string `json:"execution_id"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File Wrapper Format:**
|
||||||
|
|
||||||
|
Attachments use the standard `yao/attachment` wrapper format:
|
||||||
|
- Format: `__<uploader>://<fileID>`
|
||||||
|
- Example: `__yao.attachment://ccd472d11feb96e03a3fc468f494045c`
|
||||||
|
- Parse: `attachment.Parse(value)` → `(uploader, fileID, isWrapper)`
|
||||||
|
- Read: `attachment.Base64(ctx, value)` → base64 content
|
||||||
|
|
||||||
|
**Delivery Channels (Delivery Center decides):**
|
||||||
|
|
||||||
|
| Channel | Description | Multiple Targets |
|
||||||
|
|---------|-------------|------------------|
|
||||||
|
| `email` | Send via yao/messenger | ✅ Multiple recipients/emails |
|
||||||
|
| `webhook` | POST to external URL | ✅ Multiple URLs |
|
||||||
|
| `process` | Yao Process call | ✅ Multiple processes |
|
||||||
|
| `notify` | In-app notification | Future (auto by subscriptions) |
|
||||||
|
|
||||||
|
**Delivery Agent:**
|
||||||
|
|
||||||
|
The Delivery Agent **only generates content**, does NOT decide channels:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Delivery Agent Input
|
||||||
|
type DeliveryAgentInput struct {
|
||||||
|
Robot *Robot `json:"robot"`
|
||||||
|
TriggerType TriggerType `json:"trigger"`
|
||||||
|
Inspiration *InspirationReport `json:"inspiration"` // P0
|
||||||
|
Goals *Goals `json:"goals"` // P1
|
||||||
|
Tasks []Task `json:"tasks"` // P2
|
||||||
|
Results []TaskResult `json:"results"` // P3
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery Agent Output - only content, no channels
|
||||||
|
type DeliveryAgentOutput struct {
|
||||||
|
Content *DeliveryContent `json:"content"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Agent Output:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"summary": "Sales report completed: 15 new leads processed",
|
||||||
|
"body": "## Weekly Sales Report\n\n### Summary\n...",
|
||||||
|
"attachments": [
|
||||||
|
{"title": "Sales Report.pdf", "file": "__yao.attachment://abc123"},
|
||||||
|
{"title": "Lead Analysis.xlsx", "file": "__yao.attachment://def456"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delivery Result:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DeliveryResult - returned by Delivery Center
|
||||||
|
type DeliveryResult struct {
|
||||||
|
RequestID string `json:"request_id"` // Delivery request ID
|
||||||
|
Content *DeliveryContent `json:"content"` // Agent-generated content
|
||||||
|
Results []ChannelResult `json:"results,omitempty"` // Results per channel
|
||||||
|
Success bool `json:"success"` // Overall success
|
||||||
|
Error string `json:"error,omitempty"` // Error if failed
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty"` // When delivery completed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelResult - result for a single delivery target
|
||||||
|
type ChannelResult struct {
|
||||||
|
Type DeliveryType `json:"type"` // email | webhook | process
|
||||||
|
Target string `json:"target"` // Target identifier (email, URL, process name)
|
||||||
|
Success bool `json:"success"` // Whether delivery succeeded
|
||||||
|
Recipients []string `json:"recipients,omitempty"` // Who received (for email)
|
||||||
|
Details interface{} `json:"details,omitempty"` // Channel-specific response
|
||||||
|
Error string `json:"error,omitempty"` // Error message if failed
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty"` // When this target was delivered
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config (Delivery Preferences):**
|
||||||
|
|
||||||
|
Robot config defines delivery **preferences** (Delivery Center reads and executes).
|
||||||
|
Each channel supports **multiple targets**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
delivery:
|
delivery:
|
||||||
type: email # email | file | webhook | notify
|
preferences:
|
||||||
opts:
|
email:
|
||||||
to: ["manager@company.com"]
|
enabled: true
|
||||||
|
targets: # Multiple email targets
|
||||||
|
- to: ["manager@company.com"]
|
||||||
|
cc: ["team@company.com"]
|
||||||
|
- to: ["ceo@company.com"]
|
||||||
|
subject_template: "Executive Summary"
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
enabled: true
|
||||||
|
targets: # Multiple webhook URLs
|
||||||
|
- url: "https://slack.com/webhook/sales"
|
||||||
|
- url: "https://feishu.cn/webhook/reports"
|
||||||
|
headers: {"X-Custom": "value"}
|
||||||
|
|
||||||
|
process:
|
||||||
|
enabled: true
|
||||||
|
targets: # Multiple Yao Process calls
|
||||||
|
- name: "orders.UpdateStatus"
|
||||||
|
args: ["completed"]
|
||||||
|
- name: "audit.LogDelivery"
|
||||||
|
|
||||||
|
# Note: notify handled by Delivery Center based on user subscriptions (future)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
|
||||||
|
| Scenario | Channels | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| Event callback | `process` | DB change → Robot → Update data via Process |
|
||||||
|
| Multi-channel notify | `email` + `webhook` | Send to multiple emails and Slack/飞书 |
|
||||||
|
| Data pipeline | `process` | Robot result → Save to DB → Update dashboard |
|
||||||
|
|
||||||
### 4.7 P5: Learn
|
### 4.7 P5: Learn
|
||||||
|
|
||||||
Save to KB:
|
Save to KB:
|
||||||
|
|
@ -524,7 +707,7 @@ type Config struct {
|
||||||
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
|
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
|
||||||
Learn *Learn `json:"learn,omitempty"` // learning for private KB
|
Learn *Learn `json:"learn,omitempty"` // learning for private KB
|
||||||
Resources *Resources `json:"resources"`
|
Resources *Resources `json:"resources"`
|
||||||
Delivery *Delivery `json:"delivery"`
|
Delivery *DeliveryPreferences `json:"delivery,omitempty"`
|
||||||
Events []Event `json:"events,omitempty"`
|
Events []Event `json:"events,omitempty"`
|
||||||
Executor *Executor `json:"executor,omitempty"` // executor mode settings
|
Executor *Executor `json:"executor,omitempty"` // executor mode settings
|
||||||
}
|
}
|
||||||
|
|
@ -564,10 +747,10 @@ const (
|
||||||
type DeliveryType string
|
type DeliveryType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DeliveryEmail DeliveryType = "email"
|
DeliveryEmail DeliveryType = "email" // Email via yao/messenger
|
||||||
DeliveryFile DeliveryType = "file"
|
DeliveryWebhook DeliveryType = "webhook" // POST to external URL
|
||||||
DeliveryWebhook DeliveryType = "webhook"
|
DeliveryProcess DeliveryType = "process" // Yao Process call
|
||||||
DeliveryNotify DeliveryType = "notify"
|
DeliveryNotify DeliveryType = "notify" // In-app notification (future)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExecStatus - execution status enum
|
// ExecStatus - execution status enum
|
||||||
|
|
@ -578,6 +761,7 @@ const (
|
||||||
ExecRunning ExecStatus = "running"
|
ExecRunning ExecStatus = "running"
|
||||||
ExecCompleted ExecStatus = "completed"
|
ExecCompleted ExecStatus = "completed"
|
||||||
ExecFailed ExecStatus = "failed"
|
ExecFailed ExecStatus = "failed"
|
||||||
|
ExecCancelled ExecStatus = "cancelled"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RobotStatus - matches __yao.member.robot_status enum
|
// RobotStatus - matches __yao.member.robot_status enum
|
||||||
|
|
@ -660,10 +844,46 @@ type MCP struct {
|
||||||
Tools []string `json:"tools,omitempty"` // empty = all
|
Tools []string `json:"tools,omitempty"` // empty = all
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery
|
// DeliveryPreferences - Robot delivery preferences (read by Delivery Center)
|
||||||
type Delivery struct {
|
// Each channel supports multiple targets
|
||||||
Type DeliveryType `json:"type"`
|
type DeliveryPreferences struct {
|
||||||
Opts map[string]interface{} `json:"opts"`
|
Email *EmailPreference `json:"email,omitempty"`
|
||||||
|
Webhook *WebhookPreference `json:"webhook,omitempty"`
|
||||||
|
Process *ProcessPreference `json:"process,omitempty"`
|
||||||
|
// notify is handled automatically based on user subscriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []EmailTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailTarget struct {
|
||||||
|
To []string `json:"to"` // Recipient addresses
|
||||||
|
Template string `json:"template,omitempty"` // Email template ID
|
||||||
|
Subject string `json:"subject,omitempty"` // Subject template
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []WebhookTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookTarget struct {
|
||||||
|
URL string `json:"url"` // Webhook URL
|
||||||
|
Method string `json:"method,omitempty"` // HTTP method (default: POST)
|
||||||
|
Headers map[string]string `json:"headers,omitempty"` // Custom headers
|
||||||
|
Secret string `json:"secret,omitempty"` // Signing secret
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []ProcessTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessTarget struct {
|
||||||
|
Process string `json:"process"` // Yao Process name, e.g., "orders.UpdateStatus"
|
||||||
|
Args []any `json:"args,omitempty"` // Additional arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecutorMode - executor mode enum
|
// ExecutorMode - executor mode enum
|
||||||
|
|
|
||||||
|
|
@ -854,10 +854,10 @@ const (
|
||||||
type DeliveryType string
|
type DeliveryType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DeliveryEmail DeliveryType = "email"
|
DeliveryEmail DeliveryType = "email" // Email via yao/messenger
|
||||||
DeliveryFile DeliveryType = "file"
|
DeliveryWebhook DeliveryType = "webhook" // POST to external URL
|
||||||
DeliveryWebhook DeliveryType = "webhook"
|
DeliveryProcess DeliveryType = "process" // Yao Process call
|
||||||
DeliveryNotify DeliveryType = "notify"
|
DeliveryNotify DeliveryType = "notify" // In-app notification (future)
|
||||||
)
|
)
|
||||||
|
|
||||||
// DedupResult - deduplication result
|
// DedupResult - deduplication result
|
||||||
|
|
@ -954,7 +954,7 @@ type Config struct {
|
||||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||||
Resources *Resources `json:"resources,omitempty"`
|
Resources *Resources `json:"resources,omitempty"`
|
||||||
Delivery *Delivery `json:"delivery,omitempty"`
|
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // see section 6.2
|
||||||
Events []Event `json:"events,omitempty"`
|
Events []Event `json:"events,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1134,11 +1134,7 @@ type MCPConfig struct {
|
||||||
Tools []string `json:"tools,omitempty"` // empty = all
|
Tools []string `json:"tools,omitempty"` // empty = all
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery - output delivery
|
// Note: Delivery preferences moved to DeliveryPreferences (see section 6.2)
|
||||||
type Delivery struct {
|
|
||||||
Type DeliveryType `json:"type"`
|
|
||||||
Opts map[string]interface{} `json:"opts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event - event trigger config
|
// Event - event trigger config
|
||||||
type Event struct {
|
type Event struct {
|
||||||
|
|
@ -1173,6 +1169,7 @@ type Robot struct {
|
||||||
SystemPrompt string `json:"system_prompt"`
|
SystemPrompt string `json:"system_prompt"`
|
||||||
Status RobotStatus `json:"robot_status"`
|
Status RobotStatus `json:"robot_status"`
|
||||||
AutonomousMode bool `json:"autonomous_mode"`
|
AutonomousMode bool `json:"autonomous_mode"`
|
||||||
|
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
|
||||||
|
|
||||||
// Parsed config (from robot_config JSON field)
|
// Parsed config (from robot_config JSON field)
|
||||||
Config *Config `json:"-"`
|
Config *Config `json:"-"`
|
||||||
|
|
@ -1241,7 +1238,7 @@ func (r *Robot) GetExecutions() []*Execution {
|
||||||
// Relationship: 1 Execution = 1 job.Job
|
// Relationship: 1 Execution = 1 job.Job
|
||||||
type Execution struct {
|
type Execution struct {
|
||||||
ID string `json:"id"` // unique execution ID
|
ID string `json:"id"` // unique execution ID
|
||||||
MemberID string `json:"member_id"` // robot member ID
|
MemberID string `json:"member_id"` // robot member ID (globally unique)
|
||||||
TeamID string `json:"team_id"`
|
TeamID string `json:"team_id"`
|
||||||
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
||||||
StartTime time.Time `json:"start_time"`
|
StartTime time.Time `json:"start_time"`
|
||||||
|
|
@ -1309,9 +1306,11 @@ type Goals struct {
|
||||||
Delivery *DeliveryTarget `json:"delivery,omitempty"` // where to send results (for P4)
|
Delivery *DeliveryTarget `json:"delivery,omitempty"` // where to send results (for P4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryTarget - where to deliver results (defined in P1, used in P4)
|
// DeliveryTarget - where to deliver results (defined in P1, used by P4)
|
||||||
|
// Note: This is a hint from P1 Goals. Actual delivery is handled by Delivery Center
|
||||||
|
// based on Robot/User preferences, not strictly by this target.
|
||||||
type DeliveryTarget struct {
|
type DeliveryTarget struct {
|
||||||
Type DeliveryType `json:"type"` // email | webhook | report | notification
|
Type DeliveryType `json:"type"` // Preferred delivery type
|
||||||
Recipients []string `json:"recipients,omitempty"` // email addresses, webhook URLs, user IDs
|
Recipients []string `json:"recipients,omitempty"` // email addresses, webhook URLs, user IDs
|
||||||
Format string `json:"format,omitempty"` // markdown | html | json | text
|
Format string `json:"format,omitempty"` // markdown | html | json | text
|
||||||
Template string `json:"template,omitempty"` // template name or inline template
|
Template string `json:"template,omitempty"` // template name or inline template
|
||||||
|
|
@ -1399,15 +1398,104 @@ type ValidationResult struct {
|
||||||
ReplyContent string `json:"reply_content,omitempty"` // content for next turn (if NeedReply)
|
ReplyContent string `json:"reply_content,omitempty"` // content for next turn (if NeedReply)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryResult - P4 delivery output
|
// DeliveryRequest - pushed to Delivery Center
|
||||||
|
// Agent only generates content, Delivery Center decides channels based on preferences
|
||||||
|
type DeliveryRequest struct {
|
||||||
|
Content *DeliveryContent `json:"content"` // Agent-generated content
|
||||||
|
Context *DeliveryContext `json:"context"` // Tracking info
|
||||||
|
// No Channels field - Delivery Center decides based on Robot/User preferences
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContent - content generated by Delivery Agent
|
||||||
|
type DeliveryContent struct {
|
||||||
|
Summary string `json:"summary"` // Brief summary (1-2 sentences)
|
||||||
|
Body string `json:"body"` // Full markdown report
|
||||||
|
Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryAttachment - task output attachment with metadata
|
||||||
|
// File uses wrapper format: __<uploader>://<fileID>
|
||||||
|
// Example: __yao.attachment://ccd472d11feb96e03a3fc468f494045c
|
||||||
|
// Parse with attachment.Parse(value) → (uploader, fileID, isWrapper)
|
||||||
|
type DeliveryAttachment struct {
|
||||||
|
Title string `json:"title"` // Human-readable title, e.g., "Market Analysis Report"
|
||||||
|
Description string `json:"description,omitempty"` // Description of what this artifact is
|
||||||
|
TaskID string `json:"task_id,omitempty"` // Which task produced this artifact
|
||||||
|
File string `json:"file"` // Wrapper format: __<uploader>://<fileID>
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContext - tracking and audit info
|
||||||
|
type DeliveryContext struct {
|
||||||
|
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||||
|
ExecutionID string `json:"execution_id"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryPreferences - Robot/User delivery preferences (read by Delivery Center)
|
||||||
|
// Each channel supports multiple targets
|
||||||
|
type DeliveryPreferences struct {
|
||||||
|
Email *EmailPreference `json:"email,omitempty"`
|
||||||
|
Webhook *WebhookPreference `json:"webhook,omitempty"`
|
||||||
|
Process *ProcessPreference `json:"process,omitempty"`
|
||||||
|
// notify is handled automatically based on user subscriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailPreference - multiple email targets
|
||||||
|
type EmailPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []EmailTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailTarget struct {
|
||||||
|
To []string `json:"to"` // Recipient addresses
|
||||||
|
Template string `json:"template,omitempty"` // Email template ID
|
||||||
|
Subject string `json:"subject,omitempty"` // Subject template (default: content.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookPreference - multiple webhook targets
|
||||||
|
type WebhookPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []WebhookTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookTarget struct {
|
||||||
|
URL string `json:"url"` // Webhook URL
|
||||||
|
Method string `json:"method,omitempty"` // HTTP method (default: POST)
|
||||||
|
Headers map[string]string `json:"headers,omitempty"` // Custom headers
|
||||||
|
Secret string `json:"secret,omitempty"` // Signing secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessPreference - multiple Yao Process targets
|
||||||
|
type ProcessPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []ProcessTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessTarget struct {
|
||||||
|
Process string `json:"process"` // Yao Process name, e.g., "orders.UpdateStatus"
|
||||||
|
Args []any `json:"args,omitempty"` // Additional args (DeliveryContent passed as first arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryResult - P4 delivery output (returned by Delivery Center)
|
||||||
type DeliveryResult struct {
|
type DeliveryResult struct {
|
||||||
Type DeliveryType `json:"type"`
|
RequestID string `json:"request_id"` // Delivery request ID
|
||||||
Success bool `json:"success"`
|
Content *DeliveryContent `json:"content"` // Agent-generated content
|
||||||
Recipients []string `json:"recipients,omitempty"` // who received
|
Results []ChannelResult `json:"results,omitempty"` // Results per channel
|
||||||
Content string `json:"content,omitempty"` // formatted content delivered
|
Success bool `json:"success"` // Overall success
|
||||||
Details interface{} `json:"details,omitempty"` // channel-specific response
|
Error string `json:"error,omitempty"` // Error if failed
|
||||||
Error string `json:"error,omitempty"`
|
SentAt *time.Time `json:"sent_at,omitempty"` // When delivery completed
|
||||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
}
|
||||||
|
|
||||||
|
// ChannelResult - result for a single delivery target
|
||||||
|
type ChannelResult struct {
|
||||||
|
Type DeliveryType `json:"type"` // email | webhook | process
|
||||||
|
Target string `json:"target"` // Target identifier (email, URL, process name)
|
||||||
|
Success bool `json:"success"` // Whether delivery succeeded
|
||||||
|
Recipients []string `json:"recipients,omitempty"` // Who received (for email)
|
||||||
|
Details interface{} `json:"details,omitempty"` // Channel-specific response
|
||||||
|
Error string `json:"error,omitempty"` // Error message if failed
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty"` // When this target was delivered
|
||||||
}
|
}
|
||||||
|
|
||||||
// LearningEntry - knowledge to save
|
// LearningEntry - knowledge to save
|
||||||
|
|
@ -1893,3 +1981,493 @@ Supported assertion types:
|
||||||
- `type` - type checking (with optional path)
|
- `type` - type checking (with optional path)
|
||||||
- `script` - custom script validation
|
- `script` - custom script validation
|
||||||
- `agent` - AI agent validation
|
- `agent` - AI agent validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. P4 Delivery Implementation
|
||||||
|
|
||||||
|
### 6.1 Overview
|
||||||
|
|
||||||
|
P4 Delivery summarizes P3 execution results and delivers to configured channels.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ delivery.go (P4 Entry) │
|
||||||
|
│ - DeliveryExecution: main entry point │
|
||||||
|
│ - Calls Delivery Agent with full execution context │
|
||||||
|
│ - Routes DeliveryContent to configured channels │
|
||||||
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────┴────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Delivery Agent │ │ Delivery Center │
|
||||||
|
│ - Summarize │ │ - sendEmail() │
|
||||||
|
│ - Format body │ │ - postWebhook() │
|
||||||
|
│ - List files │ │ - callProcess() │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Delivery Request Structure
|
||||||
|
|
||||||
|
P4 generates a `DeliveryRequest` with **only content** and pushes to Delivery Center.
|
||||||
|
**Delivery Center decides channels** based on Robot/User preferences.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DeliveryRequest - pushed to Delivery Center
|
||||||
|
// No Channels - Delivery Center decides based on preferences
|
||||||
|
type DeliveryRequest struct {
|
||||||
|
Content *DeliveryContent `json:"content"` // Agent-generated content
|
||||||
|
Context *DeliveryContext `json:"context"` // Tracking info
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContent - content generated by Delivery Agent
|
||||||
|
type DeliveryContent struct {
|
||||||
|
Summary string `json:"summary"` // Brief 1-2 sentence summary
|
||||||
|
Body string `json:"body"` // Full markdown report
|
||||||
|
Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts from P3
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryAttachment - file attachment with metadata
|
||||||
|
type DeliveryAttachment struct {
|
||||||
|
Title string `json:"title"` // Human-readable title
|
||||||
|
Description string `json:"description,omitempty"` // What this artifact is
|
||||||
|
TaskID string `json:"task_id,omitempty"` // Which task produced this
|
||||||
|
File string `json:"file"` // Wrapper: __<uploader>://<fileID>
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContext - tracking and audit info
|
||||||
|
type DeliveryContext struct {
|
||||||
|
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||||
|
ExecutionID string `json:"execution_id"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example DeliveryRequest:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"summary": "Sales report completed: 15 new leads",
|
||||||
|
"body": "## Weekly Sales Report\n...",
|
||||||
|
"attachments": [{"title": "Report.pdf", "file": "__yao.attachment://abc123"}]
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"member_id": "mem_abc123",
|
||||||
|
"execution_id": "exec_xyz789",
|
||||||
|
"trigger_type": "clock",
|
||||||
|
"team_id": "team_123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Channel Decision by Delivery Center:**
|
||||||
|
|
||||||
|
Delivery Center reads Robot/User preferences and executes delivery to all enabled targets:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DeliveryPreferences - from Robot config (each channel supports multiple targets)
|
||||||
|
type DeliveryPreferences struct {
|
||||||
|
Email *EmailPreference `json:"email,omitempty"`
|
||||||
|
Webhook *WebhookPreference `json:"webhook,omitempty"`
|
||||||
|
Process *ProcessPreference `json:"process,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []EmailTarget `json:"targets"` // Multiple email targets
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []WebhookTarget `json:"targets"` // Multiple webhook URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessPreference struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Targets []ProcessTarget `json:"targets"` // Multiple Yao Process calls
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 File Wrapper Format
|
||||||
|
|
||||||
|
Attachments use the standard `yao/attachment` wrapper format:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Format: __<uploader>://<fileID>
|
||||||
|
// Example: __yao.attachment://ccd472d11feb96e03a3fc468f494045c
|
||||||
|
|
||||||
|
import "github.com/yaoapp/yao/attachment"
|
||||||
|
|
||||||
|
// Parse wrapper to get uploader and fileID
|
||||||
|
uploader, fileID, isWrapper := attachment.Parse(wrapper)
|
||||||
|
// uploader: "__yao.attachment"
|
||||||
|
// fileID: "ccd472d11feb96e03a3fc468f494045c"
|
||||||
|
// isWrapper: true
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
manager := attachment.Managers[uploader]
|
||||||
|
fileInfo, err := manager.Info(ctx, fileID)
|
||||||
|
|
||||||
|
// Read file content as base64
|
||||||
|
base64Content := attachment.Base64(ctx, wrapper)
|
||||||
|
|
||||||
|
// Read with data URI format
|
||||||
|
dataURI := attachment.Base64(ctx, wrapper, true)
|
||||||
|
// "data:image/png;base64,..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Delivery Agent
|
||||||
|
|
||||||
|
The Delivery Agent **only generates content**, does NOT decide channels.
|
||||||
|
Channel decisions are made by Delivery Center based on Robot/User preferences.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```go
|
||||||
|
type DeliveryAgentInput struct {
|
||||||
|
Robot *Robot `json:"robot"` // Robot identity and config
|
||||||
|
TriggerType TriggerType `json:"trigger"` // clock | human | event
|
||||||
|
Inspiration *InspirationReport `json:"inspiration"` // P0 (clock only)
|
||||||
|
Goals *Goals `json:"goals"` // P1
|
||||||
|
Tasks []Task `json:"tasks"` // P2
|
||||||
|
Results []TaskResult `json:"results"` // P3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```go
|
||||||
|
// DeliveryAgentOutput - only content, no channels
|
||||||
|
type DeliveryAgentOutput struct {
|
||||||
|
Content *DeliveryContent `json:"content"` // Generated content
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent Responsibilities:**
|
||||||
|
|
||||||
|
The agent focuses on content generation:
|
||||||
|
- **Summary**: Brief 1-2 sentence summary of execution results
|
||||||
|
- **Body**: Full markdown report with details
|
||||||
|
- **Attachments**: Select which P3-generated files to include
|
||||||
|
|
||||||
|
**Example Output:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"summary": "Sales report completed: 15 new leads processed, 3 high-priority",
|
||||||
|
"body": "## Weekly Sales Report\n\n### Summary\n- Total leads: 15\n- High priority: 3\n...",
|
||||||
|
"attachments": [
|
||||||
|
{"title": "Sales Report.pdf", "task_id": "task_1", "file": "__yao.attachment://abc123"},
|
||||||
|
{"title": "Lead Analysis.xlsx", "task_id": "task_2", "file": "__yao.attachment://def456"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Global Email Configuration
|
||||||
|
|
||||||
|
Email delivery uses global configuration for channel selection and Robot-specific sender identity:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// types/config_global.go
|
||||||
|
|
||||||
|
// DefaultEmailChannel returns the default messenger channel name
|
||||||
|
// Default: "email" (maps to messengers/channels.yao)
|
||||||
|
func DefaultEmailChannel() string
|
||||||
|
|
||||||
|
// SetDefaultEmailChannel sets the default channel (call during agent init)
|
||||||
|
func SetDefaultEmailChannel(channel string)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
- `DefaultEmailChannel()` - returns the messenger channel name for email delivery
|
||||||
|
- `Robot.RobotEmail` - used as the `From` address when sending emails
|
||||||
|
- If `RobotEmail` is empty, falls back to provider's default `from` address
|
||||||
|
|
||||||
|
### 6.6 Delivery Center
|
||||||
|
|
||||||
|
The Delivery Center receives `DeliveryRequest`, reads preferences, and executes delivery to **all enabled targets**.
|
||||||
|
|
||||||
|
**Current implementation:** Internal to P4 (in `executor/delivery.go`)
|
||||||
|
**Future:** Can be extracted to standalone `yao/delivery` package
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DeliveryCenter - handles delivery execution to multiple targets
|
||||||
|
type DeliveryCenter struct {
|
||||||
|
messenger *messenger.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver - main entry point
|
||||||
|
func (dc *DeliveryCenter) Deliver(ctx context.Context, req *DeliveryRequest) *DeliveryResult {
|
||||||
|
requestID := generateID()
|
||||||
|
prefs := dc.getDeliveryPreferences(ctx, req.Context.MemberID)
|
||||||
|
|
||||||
|
var results []ChannelResult
|
||||||
|
allSuccess := true
|
||||||
|
|
||||||
|
// Email - send to all targets (robot passed for From address)
|
||||||
|
if prefs.Email != nil && prefs.Email.Enabled {
|
||||||
|
for _, target := range prefs.Email.Targets {
|
||||||
|
result := dc.sendEmail(ctx, req.Content, target, req.Context, robot)
|
||||||
|
results = append(results, result)
|
||||||
|
if !result.Success {
|
||||||
|
allSuccess = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook - POST to all targets
|
||||||
|
if prefs.Webhook != nil && prefs.Webhook.Enabled {
|
||||||
|
for _, target := range prefs.Webhook.Targets {
|
||||||
|
result := dc.postWebhook(ctx, req.Content, target)
|
||||||
|
results = append(results, result)
|
||||||
|
if !result.Success {
|
||||||
|
allSuccess = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process - call all targets
|
||||||
|
if prefs.Process != nil && prefs.Process.Enabled {
|
||||||
|
for _, target := range prefs.Process.Targets {
|
||||||
|
result := dc.callProcess(ctx, req.Content, target)
|
||||||
|
results = append(results, result)
|
||||||
|
if !result.Success {
|
||||||
|
allSuccess = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future: auto-notify based on user subscriptions
|
||||||
|
// dc.sendNotifications(ctx, req)
|
||||||
|
|
||||||
|
return &DeliveryResult{
|
||||||
|
RequestID: requestID,
|
||||||
|
Content: req.Content,
|
||||||
|
Success: allSuccess,
|
||||||
|
Results: results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 Channel Handlers
|
||||||
|
|
||||||
|
Each delivery channel is handled by dedicated methods in DeliveryCenter:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// sendEmail - send to a single email target
|
||||||
|
// Uses Robot.RobotEmail as From address and global DefaultEmailChannel()
|
||||||
|
func (dc *DeliveryCenter) sendEmail(
|
||||||
|
ctx context.Context,
|
||||||
|
content *DeliveryContent,
|
||||||
|
target EmailTarget,
|
||||||
|
deliveryCtx *DeliveryContext,
|
||||||
|
robot *Robot,
|
||||||
|
) ChannelResult {
|
||||||
|
// Convert attachments to messenger format
|
||||||
|
var attachments []messenger.Attachment
|
||||||
|
for _, att := range content.Attachments {
|
||||||
|
uploader, fileID, _ := attachment.Parse(att.File)
|
||||||
|
manager := attachment.Managers[uploader]
|
||||||
|
data, _ := manager.Read(ctx, fileID)
|
||||||
|
info, _ := manager.Info(ctx, fileID)
|
||||||
|
|
||||||
|
attachments = append(attachments, messenger.Attachment{
|
||||||
|
Filename: att.Title,
|
||||||
|
ContentType: info.ContentType,
|
||||||
|
Content: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := content.Summary
|
||||||
|
if target.Subject != "" {
|
||||||
|
subject = target.Subject
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &messenger.Message{
|
||||||
|
To: target.To,
|
||||||
|
Subject: subject,
|
||||||
|
Body: content.Body,
|
||||||
|
Attachments: attachments,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set From address from Robot's email (if configured)
|
||||||
|
if robot != nil && robot.RobotEmail != "" {
|
||||||
|
msg.From = robot.RobotEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global default email channel
|
||||||
|
channel := DefaultEmailChannel() // from types/config_global.go
|
||||||
|
err := dc.messenger.Send(ctx, channel, msg)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
return ChannelResult{
|
||||||
|
Type: DeliveryEmail,
|
||||||
|
Target: strings.Join(target.To, ","),
|
||||||
|
Success: err == nil,
|
||||||
|
Recipients: target.To,
|
||||||
|
SentAt: &now,
|
||||||
|
Error: errStr(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postWebhook - POST to a single webhook target
|
||||||
|
func (dc *DeliveryCenter) postWebhook(ctx context.Context, content *DeliveryContent, target WebhookTarget) ChannelResult {
|
||||||
|
payload, _ := json.Marshal(content)
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", target.URL, bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Add custom headers
|
||||||
|
for k, v := range target.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ChannelResult{
|
||||||
|
Type: DeliveryWebhook,
|
||||||
|
Target: target.URL,
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
SentAt: &now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
success := resp.StatusCode < 400
|
||||||
|
return ChannelResult{
|
||||||
|
Type: DeliveryWebhook,
|
||||||
|
Target: target.URL,
|
||||||
|
Success: success,
|
||||||
|
Details: map[string]interface{}{"status_code": resp.StatusCode},
|
||||||
|
Error: ternary(!success, fmt.Sprintf("HTTP %d", resp.StatusCode), ""),
|
||||||
|
SentAt: &now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callProcess - call a single Yao Process target
|
||||||
|
func (dc *DeliveryCenter) callProcess(ctx context.Context, content *DeliveryContent, target ProcessTarget) ChannelResult {
|
||||||
|
// DeliveryContent as first arg, then additional args
|
||||||
|
args := append([]interface{}{content}, target.Args...)
|
||||||
|
|
||||||
|
proc := process.Of(target.Process, args...)
|
||||||
|
result, err := proc.Execute()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
return ChannelResult{
|
||||||
|
Type: DeliveryProcess,
|
||||||
|
Target: target.Process,
|
||||||
|
Success: err == nil,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"process": target.Process,
|
||||||
|
"result": result,
|
||||||
|
},
|
||||||
|
Error: errStr(err),
|
||||||
|
SentAt: &now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on Notifications:**
|
||||||
|
|
||||||
|
`notify` is NOT configured per-Robot. Future Delivery Center will:
|
||||||
|
1. Check user subscription preferences after receiving DeliveryRequest
|
||||||
|
2. Automatically send in-app notifications to subscribed users
|
||||||
|
3. This is transparent to P4 and Delivery Agent
|
||||||
|
|
||||||
|
### 6.8 Execution Persistence
|
||||||
|
|
||||||
|
Robot execution history is stored in `__yao.agent_execution` table for UI display:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Model: yao/models/agent/execution.mod.yao
|
||||||
|
// Table: __yao.agent_execution
|
||||||
|
|
||||||
|
type ExecutionRecord struct {
|
||||||
|
ID int64 `json:"id,omitempty"` // Auto-increment primary key
|
||||||
|
ExecutionID string `json:"execution_id"` // Unique execution identifier
|
||||||
|
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||||
|
TeamID string `json:"team_id"` // Team ID
|
||||||
|
JobID string `json:"job_id,omitempty"` // Linked job.Job ID
|
||||||
|
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
||||||
|
|
||||||
|
// Status tracking (synced with runtime Execution)
|
||||||
|
Status ExecStatus `json:"status"` // pending | running | completed | failed | cancelled
|
||||||
|
Phase Phase `json:"phase"` // Current phase
|
||||||
|
Current *CurrentState `json:"current,omitempty"`// Current executing state (task index, progress)
|
||||||
|
Error string `json:"error,omitempty"` // Error message if failed
|
||||||
|
|
||||||
|
// Trigger input
|
||||||
|
Input *TriggerInput `json:"input,omitempty"` // Original trigger input
|
||||||
|
|
||||||
|
// Phase outputs (P0-P5)
|
||||||
|
Inspiration *InspirationReport `json:"inspiration,omitempty"` // P0 result
|
||||||
|
Goals *Goals `json:"goals,omitempty"` // P1 result
|
||||||
|
Tasks []Task `json:"tasks,omitempty"` // P2 result
|
||||||
|
Results []TaskResult `json:"results,omitempty"` // P3 results
|
||||||
|
Delivery *DeliveryResult `json:"delivery,omitempty"` // P4 result
|
||||||
|
Learning []LearningEntry `json:"learning,omitempty"` // P5 entries
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
StartTime *time.Time `json:"start_time,omitempty"`
|
||||||
|
EndTime *time.Time `json:"end_time,omitempty"`
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentState - current executing state (for JSON storage)
|
||||||
|
type CurrentState struct {
|
||||||
|
TaskIndex int `json:"task_index"` // index in Tasks slice
|
||||||
|
Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Store Implementation:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// store/execution.go
|
||||||
|
type ExecutionStore struct {
|
||||||
|
modelID string // "__yao.agent.execution"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutionStore() *ExecutionStore
|
||||||
|
|
||||||
|
// Save creates or updates an execution record
|
||||||
|
func (s *ExecutionStore) Save(ctx context.Context, record *ExecutionRecord) error
|
||||||
|
|
||||||
|
// Get retrieves an execution by execution_id
|
||||||
|
func (s *ExecutionStore) Get(ctx context.Context, executionID string) (*ExecutionRecord, error)
|
||||||
|
|
||||||
|
// List retrieves executions with filters
|
||||||
|
func (s *ExecutionStore) List(ctx context.Context, opts *ListOptions) ([]*ExecutionRecord, error)
|
||||||
|
|
||||||
|
// UpdatePhase updates the current phase and its data
|
||||||
|
func (s *ExecutionStore) UpdatePhase(ctx context.Context, executionID string, phase Phase, data interface{}) error
|
||||||
|
|
||||||
|
// UpdateStatus updates the execution status
|
||||||
|
func (s *ExecutionStore) UpdateStatus(ctx context.Context, executionID string, status ExecStatus, errorMsg string) error
|
||||||
|
|
||||||
|
// UpdateCurrent updates the current executing state
|
||||||
|
func (s *ExecutionStore) UpdateCurrent(ctx context.Context, executionID string, current *CurrentState) error
|
||||||
|
|
||||||
|
// Delete removes an execution record
|
||||||
|
func (s *ExecutionStore) Delete(ctx context.Context, executionID string) error
|
||||||
|
|
||||||
|
// Conversion helpers
|
||||||
|
func FromExecution(exec *Execution) *ExecutionRecord
|
||||||
|
func (r *ExecutionRecord) ToExecution() *Execution
|
||||||
|
|
||||||
|
type ListOptions struct {
|
||||||
|
MemberID string // Filter by robot member ID (globally unique)
|
||||||
|
TeamID string // Filter by team
|
||||||
|
Status ExecStatus // Filter by status
|
||||||
|
TriggerType TriggerType // Filter by trigger
|
||||||
|
Limit int // Max records to return (default: 100)
|
||||||
|
Offset int // Skip records for pagination
|
||||||
|
OrderBy string // e.g., "start_time desc"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
- [x] `RobotStatus` - robot status (idle, working, paused, error, maintenance)
|
- [x] `RobotStatus` - robot status (idle, working, paused, error, maintenance)
|
||||||
- [x] `InterventionAction` - human actions (task.add, goal.adjust, etc.)
|
- [x] `InterventionAction` - human actions (task.add, goal.adjust, etc.)
|
||||||
- [x] `Priority` - priority levels (high, normal, low)
|
- [x] `Priority` - priority levels (high, normal, low)
|
||||||
- [x] `DeliveryType` - delivery types (email, file, webhook, notify)
|
- [x] `DeliveryType` - delivery types (email, webhook, process, notify)
|
||||||
- [x] `DedupResult` - dedup results (skip, merge, proceed)
|
- [x] `DedupResult` - dedup results (skip, merge, proceed)
|
||||||
- [x] `EventSource` - event sources (webhook, database)
|
- [x] `EventSource` - event sources (webhook, database)
|
||||||
- [x] `LearningType` - learning types (execution, feedback, insight)
|
- [x] `LearningType` - learning types (execution, feedback, insight)
|
||||||
|
|
@ -555,7 +555,7 @@ yao-dev-app/assistants/
|
||||||
|
|
||||||
- [x] `robot/delivery/package.yao` - config
|
- [x] `robot/delivery/package.yao` - config
|
||||||
- [x] `robot/delivery/prompts.yml` - system prompt:
|
- [x] `robot/delivery/prompts.yml` - system prompt:
|
||||||
- Input: Task results, delivery target (email, report, notification)
|
- Input: Full execution context (P0-P3 results)
|
||||||
- Output: Formatted delivery content
|
- Output: Formatted delivery content
|
||||||
- Style: Clear, professional
|
- Style: Clear, professional
|
||||||
|
|
||||||
|
|
@ -869,92 +869,285 @@ Created new `yao/assert` package for universal assertion/validation:
|
||||||
|
|
||||||
**Depends on:** Phase 9 (P3 Run)
|
**Depends on:** Phase 9 (P3 Run)
|
||||||
|
|
||||||
### 10.1 Delivery Agent Setup
|
### 10.1 Execution Persistence (Prerequisite) ✅
|
||||||
|
|
||||||
- [ ] `robot/delivery/package.yao` - Delivery Agent config
|
> **Background:** Each Robot execution (P0-P5) needs persistent storage for UI history queries.
|
||||||
- [ ] `robot/delivery/prompts.yml` - delivery prompts
|
|
||||||
|
|
||||||
### 10.2 Implementation
|
- [x] `yao/models/agent/execution.mod.yao` - Execution record model (`agent_execution` table)
|
||||||
|
- [x] id, execution_id (unique)
|
||||||
|
- [x] member_id (globally unique), team_id, job_id
|
||||||
|
- [x] trigger_type (enum: clock, human, event)
|
||||||
|
- [x] **Status tracking** (synced with runtime Execution):
|
||||||
|
- [x] status (enum: pending, running, completed, failed, cancelled)
|
||||||
|
- [x] phase (enum: inspiration, goals, tasks, run, delivery, learning)
|
||||||
|
- [x] current (JSON) - current executing state (task_index, progress)
|
||||||
|
- [x] error - error message if failed
|
||||||
|
- [x] input (JSON) - trigger input
|
||||||
|
- [x] **Phase outputs** (P0-P5):
|
||||||
|
- [x] inspiration (JSON) - P0 output
|
||||||
|
- [x] goals (JSON) - P1 output
|
||||||
|
- [x] tasks (JSON) - P2 output
|
||||||
|
- [x] results (JSON) - P3 output
|
||||||
|
- [x] delivery (JSON) - P4 output
|
||||||
|
- [x] learning (JSON) - P5 output
|
||||||
|
- [x] **Timestamps**: start_time, end_time, created_at, updated_at
|
||||||
|
- [x] Relations: member (hasOne __yao.member)
|
||||||
|
- [x] `agent/robot/store/execution.go` - Execution record storage
|
||||||
|
- [x] `Save(ctx, record)` - create or update execution record
|
||||||
|
- [x] `Get(ctx, execID)` - get execution by ID
|
||||||
|
- [x] `List(ctx, opts)` - query execution history with filters
|
||||||
|
- [x] `UpdatePhase(ctx, execID, phase, data)` - update current phase and data
|
||||||
|
- [x] `UpdateStatus(ctx, execID, status, error)` - update execution status
|
||||||
|
- [x] `UpdateCurrent(ctx, execID, current)` - update current executing state
|
||||||
|
- [x] `Delete(ctx, execID)` - delete execution record
|
||||||
|
- [x] `FromExecution(exec, robotID)` - convert runtime Execution to record
|
||||||
|
- [x] `ToExecution()` - convert record to runtime Execution
|
||||||
|
- [x] Tests: `agent/robot/store/execution_test.go` (9 test groups, all passing)
|
||||||
|
- [x] Integrate into Executor - call `UpdatePhase()` after each phase completes
|
||||||
|
- [x] Added `SkipPersistence` config option to `executor/types/Config`
|
||||||
|
- [x] Added `ExecutionStore` to `executor/standard/Executor`
|
||||||
|
- [x] Save execution record at start of `Execute()`
|
||||||
|
- [x] Call `UpdatePhase()` after each phase completes in `runPhase()`
|
||||||
|
- [x] Call `UpdateStatus()` on status changes (running, completed, failed)
|
||||||
|
|
||||||
- [ ] `executor/delivery.go` - `RunDelivery(ctx, exec, data)` - real implementation
|
### 10.2 Messenger Attachment Support ✅
|
||||||
- [ ] `executor/delivery.go` - build delivery content from results
|
|
||||||
- [ ] `executor/delivery.go` - support email delivery
|
|
||||||
- [ ] `executor/delivery.go` - support file delivery
|
|
||||||
- [ ] `executor/delivery.go` - support webhook delivery
|
|
||||||
- [ ] `executor/delivery.go` - support notify delivery
|
|
||||||
|
|
||||||
### 10.3 Tests
|
> **Conclusion:** All email providers now support attachments.
|
||||||
|
|
||||||
- [ ] `executor/delivery_test.go` - P4 delivery
|
**Implementation Status:**
|
||||||
- [ ] Test: delivery content generated correctly
|
|
||||||
- [ ] Test: email delivery (mock or real)
|
| Provider | Attachment Support | Implementation |
|
||||||
- [ ] Test: file delivery to configured path
|
|----------|-------------------|----------------|
|
||||||
|
| Twilio/SendGrid | ✅ Supported | `buildAttachments()` - base64 encoded |
|
||||||
|
| Mailgun | ✅ Supported | `sendEmailWithAttachments()` - multipart/form-data |
|
||||||
|
| SMTP (mailer) | ✅ Supported | `buildMessageWithAttachments()` - MIME multipart/mixed |
|
||||||
|
|
||||||
|
**Features Supported:**
|
||||||
|
- Regular attachments (Content-Disposition: attachment)
|
||||||
|
- Inline attachments (Content-Disposition: inline) with Content-ID for HTML embedding
|
||||||
|
- Multiple attachments per email
|
||||||
|
- Automatic content type detection
|
||||||
|
- Base64 encoding for SMTP (RFC 2045 compliant, 76-char line wrapping)
|
||||||
|
|
||||||
|
**Tests Added:**
|
||||||
|
- `messenger/providers/mailgun/mailgun_test.go`:
|
||||||
|
- `TestSend_EmailWithAttachments_MockServer`
|
||||||
|
- `TestSend_EmailWithInlineAttachment_MockServer`
|
||||||
|
- `TestSend_EmailWithAttachments_RealAPI`
|
||||||
|
- `messenger/providers/mailer/mailer_test.go`:
|
||||||
|
- `TestBuildMessage_WithAttachments` (single, multiple, inline, no attachments)
|
||||||
|
- `TestSend_EmailWithAttachments_RealAPI`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// messenger/types/types.go
|
||||||
|
type Attachment struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Content []byte `json:"content"`
|
||||||
|
Inline bool `json:"inline,omitempty"`
|
||||||
|
CID string `json:"cid,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported channels:
|
||||||
|
- [x] Email - Full attachment support
|
||||||
|
- [x] SMS - No attachment (text only)
|
||||||
|
- [x] WhatsApp - TBD
|
||||||
|
|
||||||
|
### 10.3 Type Updates (Prerequisite) ✅
|
||||||
|
|
||||||
|
- [x] Update `types/enums.go` - Update `DeliveryType` enum
|
||||||
|
- [x] Remove `DeliveryFile`
|
||||||
|
- [x] Add `DeliveryProcess`
|
||||||
|
- [x] Update `types/robot.go` - Delivery types for new architecture
|
||||||
|
- [x] `DeliveryResult` - update to new structure (RequestID, Content, Results[])
|
||||||
|
- [x] Add `DeliveryContent` struct
|
||||||
|
- [x] Add `DeliveryAttachment` struct
|
||||||
|
- [x] Add `DeliveryRequest` struct
|
||||||
|
- [x] Add `DeliveryContext` struct
|
||||||
|
- [x] Add `DeliveryPreferences` struct (with Email, Webhook, Process)
|
||||||
|
- [x] Add `EmailPreference`, `EmailTarget` structs
|
||||||
|
- [x] Add `WebhookPreference`, `WebhookTarget` structs
|
||||||
|
- [x] Add `ProcessPreference`, `ProcessTarget` structs
|
||||||
|
- [x] Add `ChannelResult` struct (with Target field)
|
||||||
|
- [x] Update `types/enums_test.go` - Update DeliveryType tests
|
||||||
|
- [x] Update `types/robot_test.go` - Update delivery result tests
|
||||||
|
|
||||||
|
### 10.4 Delivery Agent Setup
|
||||||
|
|
||||||
|
- [x] `robot/delivery/package.yao` - Delivery Agent config
|
||||||
|
- [x] `robot/delivery/prompts.yml` - delivery prompts
|
||||||
|
- [x] Input: Full execution context (P0-P3 results)
|
||||||
|
- [x] Output: DeliveryContent (Summary, Body, Attachments) - **only content, no channels**
|
||||||
|
- [x] Agent focuses on content generation, NOT channel selection
|
||||||
|
|
||||||
|
### 10.5 Delivery Content Structure
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DeliveryRequest - pushed to Delivery Center
|
||||||
|
// No Channels - Delivery Center decides based on preferences
|
||||||
|
type DeliveryRequest struct {
|
||||||
|
Content *DeliveryContent `json:"content"` // Agent-generated content
|
||||||
|
Context *DeliveryContext `json:"context"` // Tracking info
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContent - Content generated by Delivery Agent (only content)
|
||||||
|
type DeliveryContent struct {
|
||||||
|
Summary string `json:"summary"` // Brief 1-2 sentence summary
|
||||||
|
Body string `json:"body"` // Full markdown report
|
||||||
|
Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts from P3
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryAttachment - Task output attachment with metadata
|
||||||
|
type DeliveryAttachment struct {
|
||||||
|
Title string `json:"title"` // Human-readable title
|
||||||
|
Description string `json:"description,omitempty"` // What this artifact is
|
||||||
|
TaskID string `json:"task_id,omitempty"` // Which task produced this
|
||||||
|
File string `json:"file"` // Wrapper: __<uploader>://<fileID>
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContext - tracking info
|
||||||
|
type DeliveryContext struct {
|
||||||
|
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||||
|
ExecutionID string `json:"execution_id"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Design:**
|
||||||
|
- **Agent only generates content** (Summary, Body, Attachments)
|
||||||
|
- **Delivery Center decides channels** based on Robot/User preferences
|
||||||
|
- If webhook configured, every execution pushes automatically
|
||||||
|
|
||||||
|
**File Wrapper:**
|
||||||
|
- Format: `__<uploader>://<fileID>`
|
||||||
|
- Parse: `attachment.Parse(value)` → `(uploader, fileID, isWrapper)`
|
||||||
|
- Read: `attachment.Base64(ctx, value)` → base64 content
|
||||||
|
|
||||||
|
**Delivery Channels (each supports multiple targets):**
|
||||||
|
| Channel | Description | Multiple Targets |
|
||||||
|
|---------|-------------|------------------|
|
||||||
|
| `email` | Send via yao/messenger | ✅ Multiple recipients |
|
||||||
|
| `webhook` | POST to external URL | ✅ Multiple URLs |
|
||||||
|
| `process` | Yao Process call | ✅ Multiple processes |
|
||||||
|
| `notify` | In-app notification | Future (auto by subscriptions) |
|
||||||
|
|
||||||
|
### 10.6 Implementation
|
||||||
|
|
||||||
|
**P4 Entry (executor/delivery.go):**
|
||||||
|
- [x] `RunDelivery(ctx, exec, data)` - P4 entry point
|
||||||
|
- [x] Call Delivery Agent to generate content (only content, no channels)
|
||||||
|
- [x] Build DeliveryRequest (Content + Context)
|
||||||
|
- [x] Push to Delivery Center
|
||||||
|
- [x] Store DeliveryResult in exec.Delivery
|
||||||
|
|
||||||
|
**Delivery Center (executor/delivery_center.go):**
|
||||||
|
- [x] `DeliveryCenter.Deliver(ctx, request)` - main entry
|
||||||
|
- [x] Read Robot/User delivery preferences
|
||||||
|
- [x] Iterate through all enabled targets for each channel
|
||||||
|
- [x] Aggregate ChannelResults into DeliveryResult
|
||||||
|
|
||||||
|
**Channel Handlers (each supports multiple targets):**
|
||||||
|
- [x] `sendEmail()` - uses yao/messenger
|
||||||
|
- [x] Convert DeliveryAttachment to messenger.Attachment
|
||||||
|
- [x] Support multiple EmailTarget
|
||||||
|
- [x] Support custom subject_template per target
|
||||||
|
- [x] Use `Robot.RobotEmail` as From address (if configured)
|
||||||
|
- [x] Use global `DefaultEmailChannel()` for messenger channel selection
|
||||||
|
- [x] `postWebhook()` - POST JSON
|
||||||
|
- [x] POST DeliveryContent as JSON payload
|
||||||
|
- [x] Support multiple WebhookTarget
|
||||||
|
- [x] Support custom headers per target
|
||||||
|
- [x] `callProcess()` - Yao Process call
|
||||||
|
- [x] DeliveryContent as first arg
|
||||||
|
- [x] Support multiple ProcessTarget
|
||||||
|
- [x] Support additional args per target
|
||||||
|
|
||||||
|
### 10.7 Tests
|
||||||
|
|
||||||
|
- [x] `executor/delivery_test.go` - P4 delivery
|
||||||
|
- [x] Test: Delivery Agent generates content (only content)
|
||||||
|
- [x] Test: DeliveryCenter reads preferences
|
||||||
|
- [x] Test: Multiple email targets (TestDeliveryCenterEmail)
|
||||||
|
- [x] Test: Multiple webhook targets
|
||||||
|
- [x] Test: Multiple process targets (TestDeliveryCenterProcess)
|
||||||
|
- [x] Test: Mixed channels (email + webhook + process) (TestDeliveryCenterAllChannels)
|
||||||
|
- [x] Test: sendEmail with attachments (TestDeliveryCenterEmail)
|
||||||
|
- [x] Test: postWebhook with custom headers
|
||||||
|
- [x] Test: callProcess with args (TestDeliveryCenterProcess)
|
||||||
|
- [x] Test: Partial success (some targets fail)
|
||||||
|
- [x] Test: DeliveryResult aggregation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 11: P5 Learning Implementation
|
## Phase 11: API & Integration
|
||||||
|
|
||||||
**Goal:** Implement P5 (Learning). Full execution flow complete.
|
**Goal:** Complete API implementation, end-to-end tests. Main flow: P0 → P1 → P2 → P3 → P4.
|
||||||
|
|
||||||
**Depends on:** Phase 10 (P4 Delivery)
|
**Depends on:** Phase 10 (P4 Delivery)
|
||||||
|
|
||||||
### 11.1 Learning Agent Setup
|
> **Note:** P5 Learning is an advanced feature (async, background, user-invisible).
|
||||||
|
> Main flow works without it. Moved to Phase 12 (Advanced Features).
|
||||||
|
|
||||||
- [ ] `robot/learning/package.yao` - Learning Agent config
|
### 11.1 API Implementation
|
||||||
- [ ] `robot/learning/prompts.yml` - learning prompts
|
|
||||||
|
|
||||||
### 11.2 Store Implementation
|
|
||||||
|
|
||||||
- [ ] `store/store.go` - Store interface and struct
|
|
||||||
- [ ] `store/kb.go` - KB operations (create, save, search)
|
|
||||||
- [ ] `store/learning.go` - save learning entries to private KB
|
|
||||||
|
|
||||||
### 11.3 Implementation
|
|
||||||
|
|
||||||
- [ ] `executor/learning.go` - `RunLearning(ctx, exec, data)` - real implementation
|
|
||||||
- [ ] `executor/learning.go` - extract learnings from execution
|
|
||||||
- [ ] `executor/learning.go` - call Learning Agent
|
|
||||||
- [ ] `executor/learning.go` - save to private KB
|
|
||||||
|
|
||||||
### 11.4 Tests
|
|
||||||
|
|
||||||
- [ ] `executor/learning_test.go` - P5 learning
|
|
||||||
- [ ] Test: learnings extracted from execution
|
|
||||||
- [ ] Test: learnings saved to KB
|
|
||||||
- [ ] Test: KB can be queried for past learnings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 12: API & Integration
|
|
||||||
|
|
||||||
**Goal:** Complete API implementation, end-to-end tests.
|
|
||||||
|
|
||||||
### 12.1 API Implementation
|
|
||||||
|
|
||||||
- [ ] `api/api.go` - implement all Go API functions
|
- [ ] `api/api.go` - implement all Go API functions
|
||||||
- [ ] `api/process.go` - implement all Process handlers
|
- [ ] `api/process.go` - implement all Process handlers
|
||||||
- [ ] `api/jsapi.go` - implement JSAPI
|
- [ ] `api/jsapi.go` - implement JSAPI
|
||||||
|
|
||||||
### 12.2 End-to-End Tests
|
### 11.2 End-to-End Tests
|
||||||
|
|
||||||
- [ ] Full clock trigger flow (P0 → P5)
|
- [ ] Full clock trigger flow (P0 → P1 → P2 → P3 → P4)
|
||||||
- [ ] Human intervention flow (P1 → P5)
|
- [ ] Human intervention flow (P1 → P2 → P3 → P4)
|
||||||
- [ ] Event trigger flow (P1 → P5)
|
- [ ] Event trigger flow (P1 → P2 → P3 → P4)
|
||||||
- [ ] Concurrent execution test
|
- [ ] Concurrent execution test
|
||||||
- [ ] Pause/Resume/Stop test
|
- [ ] Pause/Resume/Stop test
|
||||||
|
|
||||||
### 12.3 Integration with OpenAPI
|
### 11.3 Integration with OpenAPI
|
||||||
|
|
||||||
- [ ] HTTP endpoints for human intervention
|
- [ ] HTTP endpoints for human intervention
|
||||||
- [ ] Webhook endpoints for events
|
- [ ] Webhook endpoints for events
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 13: Advanced Features
|
## Phase 12: Advanced Features
|
||||||
|
|
||||||
**Goal:** Implement dedup, semantic dedup, plan queue.
|
**Goal:** Implement P5 Learning, dedup, semantic dedup, plan queue.
|
||||||
|
|
||||||
### 13.1 Fast Dedup (Time-Window)
|
> **Note:** These are optional advanced features. Main flow works without them.
|
||||||
|
|
||||||
|
### 12.1 P5 Learning Implementation
|
||||||
|
|
||||||
|
> **Background:** P5 Learning is async, runs after P4 Delivery completes.
|
||||||
|
> User doesn't wait for it. Results stored in private KB for future reference.
|
||||||
|
|
||||||
|
#### 12.1.1 Learning Agent Setup
|
||||||
|
|
||||||
|
- [ ] `robot/learning/package.yao` - Learning Agent config
|
||||||
|
- [ ] `robot/learning/prompts.yml` - learning prompts
|
||||||
|
|
||||||
|
#### 12.1.2 Store Implementation
|
||||||
|
|
||||||
|
- [ ] `store/store.go` - Store interface and struct
|
||||||
|
- [ ] `store/kb.go` - KB operations (create, save, search)
|
||||||
|
- [ ] `store/learning.go` - save learning entries to private KB
|
||||||
|
|
||||||
|
#### 12.1.3 Implementation
|
||||||
|
|
||||||
|
- [ ] `executor/learning.go` - `RunLearning(ctx, exec, data)` - real implementation
|
||||||
|
- [ ] `executor/learning.go` - extract learnings from execution
|
||||||
|
- [ ] `executor/learning.go` - call Learning Agent
|
||||||
|
- [ ] `executor/learning.go` - save to private KB
|
||||||
|
|
||||||
|
#### 12.1.4 Tests
|
||||||
|
|
||||||
|
- [ ] `executor/learning_test.go` - P5 learning
|
||||||
|
- [ ] Test: learnings extracted from execution
|
||||||
|
- [ ] Test: learnings saved to KB
|
||||||
|
- [ ] Test: KB can be queried for past learnings
|
||||||
|
|
||||||
|
### 12.2 Fast Dedup (Time-Window)
|
||||||
|
|
||||||
> **Note:** Manager has `// TODO: dedup check` comment placeholder. Integrate after implementation.
|
> **Note:** Manager has `// TODO: dedup check` comment placeholder. Integrate after implementation.
|
||||||
|
|
||||||
|
|
@ -966,13 +1159,13 @@ Created new `yao/assert` package for universal assertion/validation:
|
||||||
- [ ] Integrate into Manager.Tick()
|
- [ ] Integrate into Manager.Tick()
|
||||||
- [ ] Test: dedup check/mark, window expiry
|
- [ ] Test: dedup check/mark, window expiry
|
||||||
|
|
||||||
### 13.2 Semantic Dedup
|
### 12.3 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
|
||||||
|
|
||||||
### 13.3 Plan Queue
|
### 12.4 Plan Queue
|
||||||
|
|
||||||
- [ ] `plan/plan.go` - plan queue implementation
|
- [ ] `plan/plan.go` - plan queue implementation
|
||||||
- [ ] Store planned tasks/goals
|
- [ ] Store planned tasks/goals
|
||||||
|
|
@ -1100,13 +1293,15 @@ func TestWithLLM(t *testing.T) {
|
||||||
| 7. P1 Goals | ✅ | Goal Generation Agent integration |
|
| 7. P1 Goals | ✅ | Goal Generation Agent integration |
|
||||||
| 8. P2 Tasks | ✅ | Task Planning Agent integration |
|
| 8. P2 Tasks | ✅ | Task Planning Agent integration |
|
||||||
| 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation |
|
| 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation |
|
||||||
| 10. P4 Delivery | ⬜ | Output delivery (email/file/webhook/notify) |
|
| 10. P4 Delivery | ✅ | Output delivery (email/webhook/process, notify future) |
|
||||||
| 11. P5 Learning | ⬜ | Learning Agent + KB save |
|
| 11. API & Integration | ⬜ | Complete API, end-to-end tests (main flow: P0→P1→P2→P3→P4) |
|
||||||
| 12. API & Integration | ⬜ | Complete API, end-to-end tests |
|
| 12. Advanced | ⬜ | P5 Learning, dedup, plan queue, Sandbox mode |
|
||||||
| 13. Advanced | ⬜ | Semantic dedup, plan queue, Sandbox mode (requires container infrastructure) |
|
|
||||||
|
|
||||||
Legend: ⬜ Not started | 🟡 In progress | ✅ Complete
|
Legend: ⬜ Not started | 🟡 In progress | ✅ Complete
|
||||||
|
|
||||||
|
**Main Flow (MVP):** P0 Inspiration → P1 Goals → P2 Tasks → P3 Run → P4 Delivery
|
||||||
|
**Advanced (Optional):** P5 Learning (async), Dedup, Plan Queue, Sandbox
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Commands
|
## Quick Commands
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,11 @@ func (e *Executor) mockPhaseOutput(exec *robottypes.Execution, phase robottypes.
|
||||||
}
|
}
|
||||||
case robottypes.PhaseDelivery:
|
case robottypes.PhaseDelivery:
|
||||||
exec.Delivery = &robottypes.DeliveryResult{
|
exec.Delivery = &robottypes.DeliveryResult{
|
||||||
Type: robottypes.DeliveryNotify,
|
RequestID: "dryrun-" + exec.ID,
|
||||||
|
Content: &robottypes.DeliveryContent{
|
||||||
|
Summary: "Dry-run delivery completed",
|
||||||
|
Body: "# Dry-run Delivery\n\nThis is a simulated delivery result.",
|
||||||
|
},
|
||||||
Success: true,
|
Success: true,
|
||||||
}
|
}
|
||||||
case robottypes.PhaseLearning:
|
case robottypes.PhaseLearning:
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,11 @@ func (e *Executor) mockPhaseOutput(exec *robottypes.Execution, phase robottypes.
|
||||||
}
|
}
|
||||||
case robottypes.PhaseDelivery:
|
case robottypes.PhaseDelivery:
|
||||||
exec.Delivery = &robottypes.DeliveryResult{
|
exec.Delivery = &robottypes.DeliveryResult{
|
||||||
Type: robottypes.DeliveryNotify,
|
RequestID: "sandbox-" + exec.ID,
|
||||||
|
Content: &robottypes.DeliveryContent{
|
||||||
|
Summary: "Sandbox delivery completed",
|
||||||
|
Body: "# Sandbox Delivery\n\nThis is a simulated sandbox delivery result.",
|
||||||
|
},
|
||||||
Success: true,
|
Success: true,
|
||||||
}
|
}
|
||||||
case robottypes.PhaseLearning:
|
case robottypes.PhaseLearning:
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,397 @@
|
||||||
package standard
|
package standard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunDelivery executes P4: Delivery phase
|
// RunDelivery executes P4: Delivery phase
|
||||||
// Delivers results to configured targets
|
// Calls the Delivery Agent to generate content, then routes to Delivery Center
|
||||||
//
|
//
|
||||||
// Input:
|
// Input:
|
||||||
// - TaskResults (from P3)
|
// - Full execution context (P0-P3)
|
||||||
// - Delivery config from robot
|
// - Robot config
|
||||||
//
|
//
|
||||||
// Output:
|
// Output:
|
||||||
// - DeliveryResult with success status
|
// - DeliveryResult with content and channel results
|
||||||
//
|
//
|
||||||
// Delivery Types:
|
// Process:
|
||||||
// - DeliveryEmail: Send email
|
// 1. Call Delivery Agent with full execution context
|
||||||
// - DeliveryNotify: Send notification
|
// 2. Agent generates DeliveryContent (summary, body, attachments)
|
||||||
// - DeliveryWebhook: Call webhook
|
// 3. Route content to Delivery Center for actual delivery
|
||||||
// - DeliveryStore: Store to database
|
|
||||||
//
|
|
||||||
// TODO: Implement real delivery
|
|
||||||
func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
||||||
e.simulateStreamDelay()
|
// Get robot for identity and resources
|
||||||
|
robot := exec.GetRobot()
|
||||||
|
if robot == nil {
|
||||||
|
return fmt.Errorf("robot not found in execution")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get agent ID for delivery phase
|
||||||
|
agentID := "__yao.delivery" // default
|
||||||
|
if robot.Config != nil && robot.Config.Resources != nil {
|
||||||
|
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseDelivery)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build input for Delivery Agent
|
||||||
|
formatter := NewInputFormatter()
|
||||||
|
userContent := formatter.FormatDeliveryInput(exec, robot)
|
||||||
|
|
||||||
|
if userContent == "" {
|
||||||
|
return fmt.Errorf("no content available for delivery generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Delivery Agent
|
||||||
|
caller := NewAgentCaller()
|
||||||
|
result, err := caller.CallWithMessages(ctx, agentID, userContent)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delivery agent (%s) call failed: %w", agentID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response as JSON
|
||||||
|
// Delivery Agent returns: { "content": { "summary": "...", "body": "...", "attachments": [...] } }
|
||||||
|
data, err := result.GetJSON()
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: if not JSON, create minimal content from raw text
|
||||||
|
content := result.GetText()
|
||||||
|
if content == "" {
|
||||||
|
return fmt.Errorf("delivery agent returned empty response")
|
||||||
|
}
|
||||||
exec.Delivery = &robottypes.DeliveryResult{
|
exec.Delivery = &robottypes.DeliveryResult{
|
||||||
Type: robottypes.DeliveryNotify,
|
RequestID: generateRequestID(exec.ID),
|
||||||
|
Content: &robottypes.DeliveryContent{
|
||||||
|
Summary: truncateSummary(content, 200),
|
||||||
|
Body: content,
|
||||||
|
},
|
||||||
Success: true,
|
Success: true,
|
||||||
}
|
}
|
||||||
|
return e.routeToDeliveryCenter(ctx, exec, robot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DeliveryContent from JSON
|
||||||
|
content := parseDeliveryContent(data)
|
||||||
|
if content == nil {
|
||||||
|
return fmt.Errorf("delivery agent (%s) returned invalid content", agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DeliveryResult
|
||||||
|
exec.Delivery = &robottypes.DeliveryResult{
|
||||||
|
RequestID: generateRequestID(exec.ID),
|
||||||
|
Content: content,
|
||||||
|
Success: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to Delivery Center for actual delivery
|
||||||
|
return e.routeToDeliveryCenter(ctx, exec, robot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeToDeliveryCenter sends content to the Delivery Center for actual delivery
|
||||||
|
// The Delivery Center decides which channels to use based on robot/user preferences
|
||||||
|
func (e *Executor) routeToDeliveryCenter(ctx *robottypes.Context, exec *robottypes.Execution, robot *robottypes.Robot) error {
|
||||||
|
if exec.Delivery == nil || exec.Delivery.Content == nil {
|
||||||
|
return fmt.Errorf("no delivery content to route")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get delivery preferences from robot config
|
||||||
|
var prefs *robottypes.DeliveryPreferences
|
||||||
|
if robot.Config != nil {
|
||||||
|
prefs = robot.Config.Delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no preferences configured, skip delivery (content is still saved in exec.Delivery)
|
||||||
|
if prefs == nil || !hasActiveChannels(prefs) {
|
||||||
|
// No channels configured - mark as success but with no results
|
||||||
|
exec.Delivery.Success = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Delivery Center and execute
|
||||||
|
center := NewDeliveryCenter()
|
||||||
|
results, err := center.Deliver(ctx, exec.Delivery.Content, &robottypes.DeliveryContext{
|
||||||
|
MemberID: exec.MemberID,
|
||||||
|
ExecutionID: exec.ID,
|
||||||
|
TriggerType: exec.TriggerType,
|
||||||
|
TeamID: exec.TeamID,
|
||||||
|
}, prefs, robot)
|
||||||
|
|
||||||
|
// Update delivery result
|
||||||
|
exec.Delivery.Results = results
|
||||||
|
now := time.Now()
|
||||||
|
exec.Delivery.SentAt = &now
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
exec.Delivery.Success = false
|
||||||
|
exec.Delivery.Error = err.Error()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all channels succeeded
|
||||||
|
allSuccess := true
|
||||||
|
for _, r := range results {
|
||||||
|
if !r.Success {
|
||||||
|
allSuccess = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exec.Delivery.Success = allSuccess
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseDeliveryContent parses the Delivery Agent response into DeliveryContent
|
||||||
|
func parseDeliveryContent(data map[string]interface{}) *robottypes.DeliveryContent {
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get content object
|
||||||
|
contentData, ok := data["content"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
// Fallback: maybe the data itself is the content
|
||||||
|
contentData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
content := &robottypes.DeliveryContent{}
|
||||||
|
|
||||||
|
// Parse summary
|
||||||
|
if summary, ok := contentData["summary"].(string); ok {
|
||||||
|
content.Summary = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse body
|
||||||
|
if body, ok := contentData["body"].(string); ok {
|
||||||
|
content.Body = body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse attachments
|
||||||
|
if attachments, ok := contentData["attachments"].([]interface{}); ok {
|
||||||
|
for _, att := range attachments {
|
||||||
|
if attMap, ok := att.(map[string]interface{}); ok {
|
||||||
|
attachment := parseDeliveryAttachment(attMap)
|
||||||
|
if attachment != nil {
|
||||||
|
content.Attachments = append(content.Attachments, *attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: at least summary or body should be present
|
||||||
|
if content.Summary == "" && content.Body == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDeliveryAttachment parses a single attachment from the agent response
|
||||||
|
func parseDeliveryAttachment(data map[string]interface{}) *robottypes.DeliveryAttachment {
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
att := &robottypes.DeliveryAttachment{}
|
||||||
|
|
||||||
|
if title, ok := data["title"].(string); ok {
|
||||||
|
att.Title = title
|
||||||
|
}
|
||||||
|
if desc, ok := data["description"].(string); ok {
|
||||||
|
att.Description = desc
|
||||||
|
}
|
||||||
|
if taskID, ok := data["task_id"].(string); ok {
|
||||||
|
att.TaskID = taskID
|
||||||
|
}
|
||||||
|
if file, ok := data["file"].(string); ok {
|
||||||
|
att.File = file
|
||||||
|
}
|
||||||
|
|
||||||
|
// At minimum, need title and file
|
||||||
|
if att.Title == "" || att.File == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return att
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRequestID generates a unique request ID for delivery tracking
|
||||||
|
func generateRequestID(execID string) string {
|
||||||
|
return fmt.Sprintf("dlv-%s-%d", execID, time.Now().UnixNano()%1000000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTaskDescription extracts a description from task messages
|
||||||
|
func getTaskDescription(task robottypes.Task) string {
|
||||||
|
if len(task.Messages) == 0 {
|
||||||
|
return task.GoalRef
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get text from first message
|
||||||
|
for _, msg := range task.Messages {
|
||||||
|
if content, ok := msg.Content.(string); ok && content != "" {
|
||||||
|
// Truncate if too long
|
||||||
|
if len(content) > 100 {
|
||||||
|
return content[:97] + "..."
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to goal reference
|
||||||
|
if task.GoalRef != "" {
|
||||||
|
return task.GoalRef
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Task " + task.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateSummary truncates text to maxLen characters
|
||||||
|
func truncateSummary(text string, maxLen int) string {
|
||||||
|
if len(text) <= maxLen {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
// Find last space before maxLen to avoid cutting words
|
||||||
|
truncated := text[:maxLen]
|
||||||
|
if idx := strings.LastIndex(truncated, " "); idx > maxLen/2 {
|
||||||
|
return truncated[:idx] + "..."
|
||||||
|
}
|
||||||
|
return truncated + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasActiveChannels checks if any delivery channel is configured
|
||||||
|
func hasActiveChannels(prefs *robottypes.DeliveryPreferences) bool {
|
||||||
|
if prefs == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if prefs.Email != nil && prefs.Email.Enabled && len(prefs.Email.Targets) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if prefs.Webhook != nil && prefs.Webhook.Enabled && len(prefs.Webhook.Targets) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if prefs.Process != nil && prefs.Process.Enabled && len(prefs.Process.Targets) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDeliveryInput formats the full execution context for the Delivery Agent
|
||||||
|
func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *robottypes.Robot) string {
|
||||||
|
if exec == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Robot identity
|
||||||
|
if robot != nil && robot.Config != nil && robot.Config.Identity != nil {
|
||||||
|
sb.WriteString("## Robot Identity\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role))
|
||||||
|
if len(robot.Config.Identity.Duties) > 0 {
|
||||||
|
sb.WriteString("- **Duties**: ")
|
||||||
|
sb.WriteString(strings.Join(robot.Config.Identity.Duties, ", "))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger type
|
||||||
|
sb.WriteString("## Execution Context\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05")))
|
||||||
|
if exec.EndTime != nil {
|
||||||
|
duration := exec.EndTime.Sub(exec.StartTime)
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String()))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Inspiration (P0) - for clock trigger
|
||||||
|
if exec.Inspiration != nil && exec.Inspiration.Content != "" {
|
||||||
|
sb.WriteString("## Inspiration (P0)\n\n")
|
||||||
|
sb.WriteString(exec.Inspiration.Content)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Goals (P1)
|
||||||
|
if exec.Goals != nil && exec.Goals.Content != "" {
|
||||||
|
sb.WriteString("## Goals (P1)\n\n")
|
||||||
|
sb.WriteString(exec.Goals.Content)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks (P2)
|
||||||
|
if len(exec.Tasks) > 0 {
|
||||||
|
sb.WriteString("## Tasks (P2)\n\n")
|
||||||
|
for i, task := range exec.Tasks {
|
||||||
|
// Extract task description from messages if available
|
||||||
|
taskDesc := getTaskDescription(task)
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. **%s** - %s\n", i+1, task.ID, taskDesc))
|
||||||
|
sb.WriteString(fmt.Sprintf(" - Executor: %s (%s)\n", task.ExecutorID, task.ExecutorType))
|
||||||
|
sb.WriteString(fmt.Sprintf(" - Status: %s\n", task.Status))
|
||||||
|
if task.ExpectedOutput != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - Expected: %s\n", task.ExpectedOutput))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results (P3) - detailed
|
||||||
|
if len(exec.Results) > 0 {
|
||||||
|
sb.WriteString("## Results (P3)\n\n")
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
failCount := 0
|
||||||
|
|
||||||
|
for _, result := range exec.Results {
|
||||||
|
if result.Success {
|
||||||
|
successCount++
|
||||||
|
sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", result.TaskID))
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", result.TaskID))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Duration**: %dms\n", result.Duration))
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if result.Validation != nil {
|
||||||
|
if result.Validation.Passed {
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Validation**: ✓ Passed (score: %.2f)\n", result.Validation.Score))
|
||||||
|
} else {
|
||||||
|
sb.WriteString("- **Validation**: ✗ Failed\n")
|
||||||
|
if len(result.Validation.Issues) > 0 {
|
||||||
|
for _, issue := range result.Validation.Issues {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - %s\n", issue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output
|
||||||
|
if result.Output != nil {
|
||||||
|
sb.WriteString("\n**Output**:\n")
|
||||||
|
if output, err := json.MarshalIndent(result.Output, "", " "); err == nil {
|
||||||
|
sb.WriteString("```json\n")
|
||||||
|
sb.WriteString(string(output))
|
||||||
|
sb.WriteString("\n```\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("%v\n", result.Output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if result.Error != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n",
|
||||||
|
len(exec.Results), successCount, failCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
|
||||||
378
agent/robot/executor/standard/delivery_center.go
Normal file
378
agent/robot/executor/standard/delivery_center.go
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
package standard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/process"
|
||||||
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
"github.com/yaoapp/yao/attachment"
|
||||||
|
"github.com/yaoapp/yao/messenger"
|
||||||
|
messengerTypes "github.com/yaoapp/yao/messenger/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeliveryCenter handles routing delivery content to configured channels
|
||||||
|
// It decides which channels to use based on robot/user preferences and executes the delivery
|
||||||
|
type DeliveryCenter struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeliveryCenter creates a new DeliveryCenter instance
|
||||||
|
func NewDeliveryCenter() *DeliveryCenter {
|
||||||
|
return &DeliveryCenter{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver sends content to all configured channels based on preferences
|
||||||
|
// Returns results for each channel target and any error
|
||||||
|
func (dc *DeliveryCenter) Deliver(
|
||||||
|
ctx *robottypes.Context,
|
||||||
|
content *robottypes.DeliveryContent,
|
||||||
|
deliveryCtx *robottypes.DeliveryContext,
|
||||||
|
prefs *robottypes.DeliveryPreferences,
|
||||||
|
robotInstance *robottypes.Robot,
|
||||||
|
) ([]robottypes.ChannelResult, error) {
|
||||||
|
if content == nil {
|
||||||
|
return nil, fmt.Errorf("delivery content is nil")
|
||||||
|
}
|
||||||
|
if prefs == nil {
|
||||||
|
return nil, nil // No preferences = no delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []robottypes.ChannelResult
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
// Process email targets
|
||||||
|
if prefs.Email != nil && prefs.Email.Enabled {
|
||||||
|
for _, target := range prefs.Email.Targets {
|
||||||
|
result := dc.sendEmail(ctx.Context, content, target, deliveryCtx, robotInstance)
|
||||||
|
results = append(results, result)
|
||||||
|
if !result.Success && lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("email delivery failed: %s", result.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process webhook targets
|
||||||
|
if prefs.Webhook != nil && prefs.Webhook.Enabled {
|
||||||
|
for _, target := range prefs.Webhook.Targets {
|
||||||
|
result := dc.postWebhook(ctx.Context, content, target, deliveryCtx)
|
||||||
|
results = append(results, result)
|
||||||
|
if !result.Success && lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("webhook delivery failed: %s", result.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process process targets
|
||||||
|
if prefs.Process != nil && prefs.Process.Enabled {
|
||||||
|
for _, target := range prefs.Process.Targets {
|
||||||
|
result := dc.callProcess(ctx.Context, content, target, deliveryCtx)
|
||||||
|
results = append(results, result)
|
||||||
|
if !result.Success && lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("process delivery failed: %s", result.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendEmail sends delivery content to a single email target
|
||||||
|
func (dc *DeliveryCenter) sendEmail(
|
||||||
|
ctx context.Context,
|
||||||
|
content *robottypes.DeliveryContent,
|
||||||
|
target robottypes.EmailTarget,
|
||||||
|
deliveryCtx *robottypes.DeliveryContext,
|
||||||
|
robotInstance *robottypes.Robot,
|
||||||
|
) robottypes.ChannelResult {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Build target identifier from recipients
|
||||||
|
targetID := strings.Join(target.To, ",")
|
||||||
|
if targetID == "" {
|
||||||
|
targetID = "no-recipients"
|
||||||
|
}
|
||||||
|
|
||||||
|
result := robottypes.ChannelResult{
|
||||||
|
Type: robottypes.DeliveryEmail,
|
||||||
|
Target: targetID,
|
||||||
|
SentAt: &now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get messenger service
|
||||||
|
svc := messenger.Instance
|
||||||
|
if svc == nil {
|
||||||
|
result.Error = "messenger service not available"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build email message
|
||||||
|
msg := &messengerTypes.Message{
|
||||||
|
To: target.To,
|
||||||
|
Subject: buildEmailSubject(target.Subject, target.Template, content, deliveryCtx),
|
||||||
|
Body: buildEmailBody(target.Template, content),
|
||||||
|
Type: messengerTypes.MessageTypeEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set From address from Robot's email (if configured)
|
||||||
|
if robotInstance != nil && robotInstance.RobotEmail != "" {
|
||||||
|
msg.From = robotInstance.RobotEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert attachments
|
||||||
|
attachments := convertAttachments(ctx, content.Attachments)
|
||||||
|
if len(attachments) > 0 {
|
||||||
|
msg.Attachments = attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email using global default channel
|
||||||
|
channel := robottypes.DefaultEmailChannel()
|
||||||
|
err := svc.Send(ctx, channel, msg)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = true
|
||||||
|
result.Recipients = target.To
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// postWebhook posts delivery content to a single webhook target
|
||||||
|
func (dc *DeliveryCenter) postWebhook(
|
||||||
|
ctx context.Context,
|
||||||
|
content *robottypes.DeliveryContent,
|
||||||
|
target robottypes.WebhookTarget,
|
||||||
|
deliveryCtx *robottypes.DeliveryContext,
|
||||||
|
) robottypes.ChannelResult {
|
||||||
|
now := time.Now()
|
||||||
|
result := robottypes.ChannelResult{
|
||||||
|
Type: robottypes.DeliveryWebhook,
|
||||||
|
Target: target.URL,
|
||||||
|
SentAt: &now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build webhook payload
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"event": "robot.delivery",
|
||||||
|
"timestamp": now.Format(time.RFC3339),
|
||||||
|
"execution_id": deliveryCtx.ExecutionID,
|
||||||
|
"member_id": deliveryCtx.MemberID,
|
||||||
|
"team_id": deliveryCtx.TeamID,
|
||||||
|
"trigger_type": deliveryCtx.TriggerType,
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"summary": content.Summary,
|
||||||
|
"body": content.Body,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add attachments info (not the actual files)
|
||||||
|
if len(content.Attachments) > 0 {
|
||||||
|
attachmentInfo := make([]map[string]interface{}, 0, len(content.Attachments))
|
||||||
|
for _, att := range content.Attachments {
|
||||||
|
attachmentInfo = append(attachmentInfo, map[string]interface{}{
|
||||||
|
"title": att.Title,
|
||||||
|
"description": att.Description,
|
||||||
|
"task_id": att.TaskID,
|
||||||
|
"file": att.File,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
payload["attachments"] = attachmentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal payload
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to marshal payload: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
method := target.Method
|
||||||
|
if method == "" {
|
||||||
|
method = "POST"
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, target.URL, bytes.NewReader(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to create request: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Add custom headers
|
||||||
|
for key, value := range target.Headers {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add secret if configured (for signature verification)
|
||||||
|
if target.Secret != "" {
|
||||||
|
// TODO: Implement HMAC signature
|
||||||
|
req.Header.Set("X-Webhook-Secret", target.Secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
resp, err := dc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("request failed: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read response body
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
result.Error = fmt.Sprintf("webhook returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = true
|
||||||
|
result.Details = map[string]interface{}{
|
||||||
|
"status_code": resp.StatusCode,
|
||||||
|
"response": string(body),
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// callProcess calls a Yao Process with delivery content
|
||||||
|
func (dc *DeliveryCenter) callProcess(
|
||||||
|
ctx context.Context,
|
||||||
|
content *robottypes.DeliveryContent,
|
||||||
|
target robottypes.ProcessTarget,
|
||||||
|
deliveryCtx *robottypes.DeliveryContext,
|
||||||
|
) robottypes.ChannelResult {
|
||||||
|
now := time.Now()
|
||||||
|
result := robottypes.ChannelResult{
|
||||||
|
Type: robottypes.DeliveryProcess,
|
||||||
|
Target: target.Process,
|
||||||
|
SentAt: &now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build args: DeliveryContent as first arg, then additional args
|
||||||
|
args := make([]interface{}, 0, 1+len(target.Args))
|
||||||
|
args = append(args, map[string]interface{}{
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"summary": content.Summary,
|
||||||
|
"body": content.Body,
|
||||||
|
"attachments": content.Attachments,
|
||||||
|
},
|
||||||
|
"context": map[string]interface{}{
|
||||||
|
"execution_id": deliveryCtx.ExecutionID,
|
||||||
|
"member_id": deliveryCtx.MemberID,
|
||||||
|
"team_id": deliveryCtx.TeamID,
|
||||||
|
"trigger_type": deliveryCtx.TriggerType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
args = append(args, target.Args...)
|
||||||
|
|
||||||
|
// Create and execute process
|
||||||
|
proc, err := process.Of(target.Process, args...)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to create process: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
proc.Context = ctx
|
||||||
|
|
||||||
|
err = proc.Execute()
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = true
|
||||||
|
result.Details = proc.Value
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEmailSubject builds the email subject line
|
||||||
|
func buildEmailSubject(subject, template string, content *robottypes.DeliveryContent, ctx *robottypes.DeliveryContext) string {
|
||||||
|
// Use explicit subject if provided
|
||||||
|
if subject != "" {
|
||||||
|
return subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use template-based subject if template is specified
|
||||||
|
// TODO: Implement template rendering
|
||||||
|
if template != "" {
|
||||||
|
return fmt.Sprintf("[Robot] %s", content.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: use summary
|
||||||
|
if content.Summary != "" {
|
||||||
|
return fmt.Sprintf("[Robot] %s", content.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("[Robot] Execution %s Complete", ctx.ExecutionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEmailBody builds the email body content
|
||||||
|
func buildEmailBody(template string, content *robottypes.DeliveryContent) string {
|
||||||
|
// TODO: Implement template rendering
|
||||||
|
// For now, just use the body directly
|
||||||
|
if content.Body != "" {
|
||||||
|
return content.Body
|
||||||
|
}
|
||||||
|
return content.Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertAttachments converts DeliveryAttachment to messenger Attachment format
|
||||||
|
func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAttachment) []messengerTypes.Attachment {
|
||||||
|
if len(attachments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]messengerTypes.Attachment, 0, len(attachments))
|
||||||
|
|
||||||
|
for _, att := range attachments {
|
||||||
|
// Parse file wrapper: __<uploader>://<fileID>
|
||||||
|
uploader, fileID, isWrapper := attachment.Parse(att.File)
|
||||||
|
if !isWrapper {
|
||||||
|
// Skip non-wrapper attachments
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info from attachment manager
|
||||||
|
manager, ok := attachment.Managers[uploader]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := manager.Info(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
content, err := manager.Read(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build messenger attachment
|
||||||
|
msgAtt := messengerTypes.Attachment{
|
||||||
|
Filename: info.Filename,
|
||||||
|
ContentType: info.ContentType,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, msgAtt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
1174
agent/robot/executor/standard/delivery_test.go
Normal file
1174
agent/robot/executor/standard/delivery_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,16 +7,19 @@ import (
|
||||||
|
|
||||||
"github.com/yaoapp/yao/agent/robot/executor/types"
|
"github.com/yaoapp/yao/agent/robot/executor/types"
|
||||||
"github.com/yaoapp/yao/agent/robot/job"
|
"github.com/yaoapp/yao/agent/robot/job"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/store"
|
||||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Executor implements the standard executor with real Agent calls
|
// Executor implements the standard executor with real Agent calls
|
||||||
// This is the production executor that:
|
// This is the production executor that:
|
||||||
// - Creates Job records for tracking
|
// - Creates Job records for tracking
|
||||||
|
// - Persists execution history to database
|
||||||
// - Calls real Agents via Assistant.Stream()
|
// - Calls real Agents via Assistant.Stream()
|
||||||
// - Logs phase transitions and errors
|
// - Logs phase transitions and errors
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
config types.Config
|
config types.Config
|
||||||
|
store *store.ExecutionStore
|
||||||
execCount atomic.Int32
|
execCount atomic.Int32
|
||||||
currentCount atomic.Int32
|
currentCount atomic.Int32
|
||||||
onStart func()
|
onStart func()
|
||||||
|
|
@ -25,13 +28,16 @@ type Executor struct {
|
||||||
|
|
||||||
// New creates a new standard executor
|
// New creates a new standard executor
|
||||||
func New() *Executor {
|
func New() *Executor {
|
||||||
return &Executor{}
|
return &Executor{
|
||||||
|
store: store.NewExecutionStore(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithConfig creates a new standard executor with configuration
|
// NewWithConfig creates a new standard executor with configuration
|
||||||
func NewWithConfig(config types.Config) *Executor {
|
func NewWithConfig(config types.Config) *Executor {
|
||||||
return &Executor{
|
return &Executor{
|
||||||
config: config,
|
config: config,
|
||||||
|
store: store.NewExecutionStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +82,18 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
||||||
// Set robot reference for phase methods
|
// Set robot reference for phase methods
|
||||||
exec.SetRobot(robot)
|
exec.SetRobot(robot)
|
||||||
|
|
||||||
|
// Persist execution record to database
|
||||||
|
// Robot is identified by member_id (globally unique in __yao.member table)
|
||||||
|
if !e.config.SkipPersistence && e.store != nil {
|
||||||
|
record := store.FromExecution(exec)
|
||||||
|
if err := e.store.Save(ctx.Context, record); err != nil {
|
||||||
|
// Log warning but don't fail execution
|
||||||
|
if !e.config.SkipJobIntegration {
|
||||||
|
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist execution record: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Acquire execution slot
|
// Acquire execution slot
|
||||||
if !robot.TryAcquireSlot(exec) {
|
if !robot.TryAcquireSlot(exec) {
|
||||||
if !e.config.SkipJobIntegration && exec.JobID != "" {
|
if !e.config.SkipJobIntegration && exec.JobID != "" {
|
||||||
|
|
@ -105,6 +123,14 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
||||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err))
|
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Persist running status
|
||||||
|
if !e.config.SkipPersistence && e.store != nil {
|
||||||
|
if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecRunning, ""); err != nil {
|
||||||
|
if !e.config.SkipJobIntegration {
|
||||||
|
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist running status: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for simulated failure (for testing)
|
// Check for simulated failure (for testing)
|
||||||
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
|
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
|
||||||
|
|
@ -113,6 +139,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
||||||
if !e.config.SkipJobIntegration {
|
if !e.config.SkipJobIntegration {
|
||||||
_ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure"))
|
_ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure"))
|
||||||
}
|
}
|
||||||
|
// Persist failed status
|
||||||
|
if !e.config.SkipPersistence && e.store != nil {
|
||||||
|
_ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, "simulated failure")
|
||||||
|
}
|
||||||
return exec, nil
|
return exec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,6 +155,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
||||||
if !e.config.SkipJobIntegration {
|
if !e.config.SkipJobIntegration {
|
||||||
_ = job.FailExecution(ctx, exec, err)
|
_ = job.FailExecution(ctx, exec, err)
|
||||||
}
|
}
|
||||||
|
// Persist failed status
|
||||||
|
if !e.config.SkipPersistence && e.store != nil {
|
||||||
|
_ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, err.Error())
|
||||||
|
}
|
||||||
return exec, nil
|
return exec, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +173,14 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
||||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err))
|
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Persist completed status
|
||||||
|
if !e.config.SkipPersistence && e.store != nil {
|
||||||
|
if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecCompleted, ""); err != nil {
|
||||||
|
if !e.config.SkipJobIntegration {
|
||||||
|
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist completed status: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return exec, nil
|
return exec, nil
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +225,19 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist phase output to database
|
||||||
|
if !e.config.SkipPersistence && e.store != nil {
|
||||||
|
phaseData := e.getPhaseData(exec, phase)
|
||||||
|
if phaseData != nil {
|
||||||
|
if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, phaseData); err != nil {
|
||||||
|
// Log warning but don't fail execution
|
||||||
|
if !e.config.SkipJobIntegration {
|
||||||
|
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist phase %s data: %v", phase, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if e.config.OnPhaseEnd != nil {
|
if e.config.OnPhaseEnd != nil {
|
||||||
e.config.OnPhaseEnd(phase)
|
e.config.OnPhaseEnd(phase)
|
||||||
}
|
}
|
||||||
|
|
@ -195,6 +250,26 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getPhaseData extracts the output data for a specific phase from execution
|
||||||
|
func (e *Executor) getPhaseData(exec *robottypes.Execution, phase robottypes.Phase) interface{} {
|
||||||
|
switch phase {
|
||||||
|
case robottypes.PhaseInspiration:
|
||||||
|
return exec.Inspiration
|
||||||
|
case robottypes.PhaseGoals:
|
||||||
|
return exec.Goals
|
||||||
|
case robottypes.PhaseTasks:
|
||||||
|
return exec.Tasks
|
||||||
|
case robottypes.PhaseRun:
|
||||||
|
return exec.Results
|
||||||
|
case robottypes.PhaseDelivery:
|
||||||
|
return exec.Delivery
|
||||||
|
case robottypes.PhaseLearning:
|
||||||
|
return exec.Learning
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ExecCount returns total execution count
|
// ExecCount returns total execution count
|
||||||
func (e *Executor) ExecCount() int {
|
func (e *Executor) ExecCount() int {
|
||||||
return int(e.execCount.Load())
|
return int(e.execCount.Load())
|
||||||
|
|
|
||||||
158
agent/robot/executor/standard/executor_test.go
Normal file
158
agent/robot/executor/standard/executor_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
package standard_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/executor/standard"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/executor/types"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/store"
|
||||||
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Executor Persistence Integration Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestExecutorPersistence(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
t.Run("persists_execution_record_on_start", func(t *testing.T) {
|
||||||
|
ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{
|
||||||
|
UserID: "user_persist_001",
|
||||||
|
TeamID: "team_persist_001",
|
||||||
|
})
|
||||||
|
|
||||||
|
robot := createPersistenceTestRobot("member_persist_001", "team_persist_001")
|
||||||
|
|
||||||
|
// Create executor with persistence enabled but skip job integration
|
||||||
|
e := standard.NewWithConfig(types.Config{
|
||||||
|
SkipJobIntegration: true,
|
||||||
|
SkipPersistence: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Execute with simulated failure to ensure we get a result
|
||||||
|
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, exec)
|
||||||
|
|
||||||
|
// Verify execution record was persisted
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
record, err := s.Get(context.Background(), exec.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, record)
|
||||||
|
|
||||||
|
assert.Equal(t, exec.ID, record.ExecutionID)
|
||||||
|
assert.Equal(t, "member_persist_001", record.MemberID)
|
||||||
|
assert.Equal(t, "team_persist_001", record.TeamID)
|
||||||
|
assert.Equal(t, robottypes.TriggerHuman, record.TriggerType)
|
||||||
|
assert.Equal(t, robottypes.ExecFailed, record.Status)
|
||||||
|
assert.Equal(t, "simulated failure", record.Error)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
_ = s.Delete(context.Background(), exec.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("persists_failed_status_with_error", func(t *testing.T) {
|
||||||
|
ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{
|
||||||
|
UserID: "user_persist_002",
|
||||||
|
TeamID: "team_persist_002",
|
||||||
|
})
|
||||||
|
|
||||||
|
robot := createPersistenceTestRobot("member_persist_002", "team_persist_002")
|
||||||
|
|
||||||
|
e := standard.NewWithConfig(types.Config{
|
||||||
|
SkipJobIntegration: true,
|
||||||
|
SkipPersistence: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Execute with simulated failure
|
||||||
|
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, exec)
|
||||||
|
|
||||||
|
// Verify the record has failed status with error message
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
record, err := s.Get(context.Background(), exec.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, record)
|
||||||
|
|
||||||
|
assert.Equal(t, robottypes.ExecFailed, record.Status)
|
||||||
|
assert.Equal(t, "simulated failure", record.Error)
|
||||||
|
assert.NotNil(t, record.StartTime)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
_ = s.Delete(context.Background(), exec.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skips_persistence_when_disabled", func(t *testing.T) {
|
||||||
|
ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{
|
||||||
|
UserID: "user_persist_003",
|
||||||
|
TeamID: "team_persist_003",
|
||||||
|
})
|
||||||
|
|
||||||
|
robot := createPersistenceTestRobot("member_persist_003", "team_persist_003")
|
||||||
|
|
||||||
|
// Create executor with persistence disabled
|
||||||
|
e := standard.NewWithConfig(types.Config{
|
||||||
|
SkipJobIntegration: true,
|
||||||
|
SkipPersistence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, exec)
|
||||||
|
|
||||||
|
// Verify no record was created
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
record, err := s.Get(context.Background(), exec.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, record) // Should not exist
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func createPersistenceTestRobot(memberID, teamID string) *robottypes.Robot {
|
||||||
|
return &robottypes.Robot{
|
||||||
|
MemberID: memberID,
|
||||||
|
TeamID: teamID,
|
||||||
|
DisplayName: "Persistence Test Robot",
|
||||||
|
Status: robottypes.RobotIdle,
|
||||||
|
AutonomousMode: true,
|
||||||
|
Config: &robottypes.Config{
|
||||||
|
Identity: &robottypes.Identity{
|
||||||
|
Role: "Test Robot",
|
||||||
|
Duties: []string{"Testing persistence"},
|
||||||
|
},
|
||||||
|
Quota: &robottypes.Quota{
|
||||||
|
Max: 5,
|
||||||
|
Queue: 10,
|
||||||
|
},
|
||||||
|
Triggers: &robottypes.Triggers{
|
||||||
|
Intervene: &robottypes.TriggerSwitch{Enabled: true},
|
||||||
|
},
|
||||||
|
Resources: &robottypes.Resources{
|
||||||
|
Phases: map[robottypes.Phase]string{
|
||||||
|
robottypes.PhaseInspiration: "robot.inspiration",
|
||||||
|
robottypes.PhaseGoals: "robot.goals",
|
||||||
|
robottypes.PhaseTasks: "robot.tasks",
|
||||||
|
robottypes.PhaseRun: "robot.validation",
|
||||||
|
"validation": "robot.validation",
|
||||||
|
},
|
||||||
|
Agents: []string{"experts.text-writer", "experts.data-analyst"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -170,7 +170,7 @@ func ParseDelivery(data map[string]interface{}) *robottypes.DeliveryTarget {
|
||||||
func IsValidDeliveryType(t robottypes.DeliveryType) bool {
|
func IsValidDeliveryType(t robottypes.DeliveryType) bool {
|
||||||
switch t {
|
switch t {
|
||||||
case robottypes.DeliveryEmail, robottypes.DeliveryWebhook,
|
case robottypes.DeliveryEmail, robottypes.DeliveryWebhook,
|
||||||
robottypes.DeliveryFile, robottypes.DeliveryNotify:
|
robottypes.DeliveryProcess, robottypes.DeliveryNotify:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,7 @@ func TestParseDeliveryFromGoalsResponse(t *testing.T) {
|
||||||
if exec.Goals.Delivery.Type != "" {
|
if exec.Goals.Delivery.Type != "" {
|
||||||
validTypes := []types.DeliveryType{
|
validTypes := []types.DeliveryType{
|
||||||
types.DeliveryEmail, types.DeliveryWebhook,
|
types.DeliveryEmail, types.DeliveryWebhook,
|
||||||
types.DeliveryFile, types.DeliveryNotify,
|
types.DeliveryProcess, types.DeliveryNotify,
|
||||||
}
|
}
|
||||||
found := false
|
found := false
|
||||||
for _, vt := range validTypes {
|
for _, vt := range validTypes {
|
||||||
|
|
@ -355,7 +355,7 @@ func TestDeliveryTypeValidation(t *testing.T) {
|
||||||
validTypes := []types.DeliveryType{
|
validTypes := []types.DeliveryType{
|
||||||
types.DeliveryEmail,
|
types.DeliveryEmail,
|
||||||
types.DeliveryWebhook,
|
types.DeliveryWebhook,
|
||||||
types.DeliveryFile,
|
types.DeliveryProcess,
|
||||||
types.DeliveryNotify,
|
types.DeliveryNotify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,7 +462,7 @@ func TestParseDelivery(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("parses all valid delivery types", func(t *testing.T) {
|
t.Run("parses all valid delivery types", func(t *testing.T) {
|
||||||
validTypes := []string{"email", "webhook", "file", "notify"}
|
validTypes := []string{"email", "webhook", "process", "notify"}
|
||||||
|
|
||||||
for _, dt := range validTypes {
|
for _, dt := range validTypes {
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
|
|
|
||||||
|
|
@ -511,12 +511,17 @@ func (f *InputFormatter) FormatExecutionSummary(exec *robottypes.Execution) stri
|
||||||
// Delivery (P4)
|
// Delivery (P4)
|
||||||
if exec.Delivery != nil {
|
if exec.Delivery != nil {
|
||||||
sb.WriteString("## Delivery (P4)\n\n")
|
sb.WriteString("## Delivery (P4)\n\n")
|
||||||
sb.WriteString(fmt.Sprintf("- **Type**: %s\n", exec.Delivery.Type))
|
if exec.Delivery.Content != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Summary**: %s\n", exec.Delivery.Content.Summary))
|
||||||
|
}
|
||||||
if exec.Delivery.Success {
|
if exec.Delivery.Success {
|
||||||
sb.WriteString("- **Status**: ✓ Success\n")
|
sb.WriteString("- **Status**: ✓ Success\n")
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(fmt.Sprintf("- **Status**: ✗ Failed (%s)\n", exec.Delivery.Error))
|
sb.WriteString(fmt.Sprintf("- **Status**: ✗ Failed (%s)\n", exec.Delivery.Error))
|
||||||
}
|
}
|
||||||
|
if len(exec.Delivery.Results) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("- **Channels**: %d\n", len(exec.Delivery.Results)))
|
||||||
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -455,7 +455,11 @@ func TestInputFormatterFormatExecutionSummary(t *testing.T) {
|
||||||
{TaskID: "t2", Success: true, Duration: 200},
|
{TaskID: "t2", Success: true, Duration: 200},
|
||||||
},
|
},
|
||||||
Delivery: &types.DeliveryResult{
|
Delivery: &types.DeliveryResult{
|
||||||
Type: types.DeliveryEmail,
|
RequestID: "test-delivery-001",
|
||||||
|
Content: &types.DeliveryContent{
|
||||||
|
Summary: "Test delivery completed",
|
||||||
|
Body: "# Test Delivery\n\nTest delivery body.",
|
||||||
|
},
|
||||||
Success: true,
|
Success: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -476,7 +480,7 @@ func TestInputFormatterFormatExecutionSummary(t *testing.T) {
|
||||||
assert.Contains(t, result, "## Results (P3)")
|
assert.Contains(t, result, "## Results (P3)")
|
||||||
assert.Contains(t, result, "✓ t1")
|
assert.Contains(t, result, "✓ t1")
|
||||||
assert.Contains(t, result, "## Delivery (P4)")
|
assert.Contains(t, result, "## Delivery (P4)")
|
||||||
assert.Contains(t, result, "email")
|
assert.Contains(t, result, "Test delivery completed")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("formats execution with error", func(t *testing.T) {
|
t.Run("formats execution with error", func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ type Config struct {
|
||||||
// SkipJobIntegration skips job system integration (for testing)
|
// SkipJobIntegration skips job system integration (for testing)
|
||||||
SkipJobIntegration bool
|
SkipJobIntegration bool
|
||||||
|
|
||||||
|
// SkipPersistence skips execution record persistence (for testing)
|
||||||
|
SkipPersistence bool
|
||||||
|
|
||||||
// OnPhaseStart callback when a phase starts
|
// OnPhaseStart callback when a phase starts
|
||||||
OnPhaseStart func(phase robottypes.Phase)
|
OnPhaseStart func(phase robottypes.Phase)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -278,8 +278,12 @@ func TestCompleteExecution(t *testing.T) {
|
||||||
|
|
||||||
// Simulate execution progress
|
// Simulate execution progress
|
||||||
exec.Delivery = &types.DeliveryResult{
|
exec.Delivery = &types.DeliveryResult{
|
||||||
|
RequestID: "test-delivery-001",
|
||||||
|
Content: &types.DeliveryContent{
|
||||||
|
Summary: "Test delivery completed",
|
||||||
|
Body: "# Test Delivery\n\nThis is a test delivery result.",
|
||||||
|
},
|
||||||
Success: true,
|
Success: true,
|
||||||
Type: types.DeliveryEmail,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = job.CompleteExecution(ctx, exec)
|
err = job.CompleteExecution(ctx, exec)
|
||||||
|
|
|
||||||
707
agent/robot/store/execution.go
Normal file
707
agent/robot/store/execution.go
Normal file
|
|
@ -0,0 +1,707 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecutionRecord - persistent storage for robot execution history
|
||||||
|
// Maps to __yao.agent_execution model
|
||||||
|
type ExecutionRecord struct {
|
||||||
|
ID int64 `json:"id,omitempty"` // Auto-increment primary key
|
||||||
|
ExecutionID string `json:"execution_id"` // Unique execution identifier
|
||||||
|
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||||
|
TeamID string `json:"team_id"` // Team ID
|
||||||
|
JobID string `json:"job_id,omitempty"` // Linked job.Job ID
|
||||||
|
TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event
|
||||||
|
|
||||||
|
// Status tracking (synced with runtime Execution)
|
||||||
|
Status types.ExecStatus `json:"status"` // pending | running | completed | failed | cancelled
|
||||||
|
Phase types.Phase `json:"phase"` // Current phase
|
||||||
|
Current *CurrentState `json:"current,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
|
||||||
|
// Trigger input
|
||||||
|
Input *types.TriggerInput `json:"input,omitempty"`
|
||||||
|
|
||||||
|
// Phase outputs (P0-P5)
|
||||||
|
Inspiration *types.InspirationReport `json:"inspiration,omitempty"`
|
||||||
|
Goals *types.Goals `json:"goals,omitempty"`
|
||||||
|
Tasks []types.Task `json:"tasks,omitempty"`
|
||||||
|
Results []types.TaskResult `json:"results,omitempty"`
|
||||||
|
Delivery *types.DeliveryResult `json:"delivery,omitempty"`
|
||||||
|
Learning []types.LearningEntry `json:"learning,omitempty"`
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
StartTime *time.Time `json:"start_time,omitempty"`
|
||||||
|
EndTime *time.Time `json:"end_time,omitempty"`
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentState - current executing state (for JSON storage)
|
||||||
|
type CurrentState struct {
|
||||||
|
TaskIndex int `json:"task_index"` // index in Tasks slice
|
||||||
|
Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOptions - options for listing execution records
|
||||||
|
type ListOptions struct {
|
||||||
|
MemberID string `json:"member_id,omitempty"` // Filter by robot member ID
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
Status types.ExecStatus `json:"status,omitempty"`
|
||||||
|
TriggerType types.TriggerType `json:"trigger_type,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Offset int `json:"offset,omitempty"`
|
||||||
|
OrderBy string `json:"order_by,omitempty"` // e.g., "start_time desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionStore - persistent storage for robot execution records
|
||||||
|
type ExecutionStore struct {
|
||||||
|
modelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutionStore creates a new execution store instance
|
||||||
|
func NewExecutionStore() *ExecutionStore {
|
||||||
|
return &ExecutionStore{
|
||||||
|
modelID: "__yao.agent.execution",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save creates or updates an execution record
|
||||||
|
func (s *ExecutionStore) Save(ctx context.Context, record *ExecutionRecord) error {
|
||||||
|
mod := model.Select(s.modelID)
|
||||||
|
if mod == nil {
|
||||||
|
return fmt.Errorf("model %s not found", s.modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := s.recordToMap(record)
|
||||||
|
|
||||||
|
// Check if record exists by execution_id
|
||||||
|
existing, err := s.Get(ctx, record.ExecutionID)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
// Update existing record
|
||||||
|
_, err = mod.UpdateWhere(
|
||||||
|
model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "execution_id", Value: record.ExecutionID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update execution record: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new record
|
||||||
|
_, err = mod.Create(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create execution record: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves an execution record by execution_id
|
||||||
|
func (s *ExecutionStore) Get(ctx context.Context, executionID string) (*ExecutionRecord, error) {
|
||||||
|
mod := model.Select(s.modelID)
|
||||||
|
if mod == nil {
|
||||||
|
return nil, fmt.Errorf("model %s not found", s.modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := mod.Get(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "execution_id", Value: executionID},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get execution record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.mapToRecord(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves execution records with filters
|
||||||
|
func (s *ExecutionStore) List(ctx context.Context, opts *ListOptions) ([]*ExecutionRecord, error) {
|
||||||
|
mod := model.Select(s.modelID)
|
||||||
|
if mod == nil {
|
||||||
|
return nil, fmt.Errorf("model %s not found", s.modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := model.QueryParam{}
|
||||||
|
|
||||||
|
// Build where conditions
|
||||||
|
var wheres []model.QueryWhere
|
||||||
|
if opts != nil {
|
||||||
|
if opts.MemberID != "" {
|
||||||
|
wheres = append(wheres, model.QueryWhere{Column: "member_id", Value: opts.MemberID})
|
||||||
|
}
|
||||||
|
if opts.TeamID != "" {
|
||||||
|
wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID})
|
||||||
|
}
|
||||||
|
if opts.Status != "" {
|
||||||
|
wheres = append(wheres, model.QueryWhere{Column: "status", Value: string(opts.Status)})
|
||||||
|
}
|
||||||
|
if opts.TriggerType != "" {
|
||||||
|
wheres = append(wheres, model.QueryWhere{Column: "trigger_type", Value: string(opts.TriggerType)})
|
||||||
|
}
|
||||||
|
|
||||||
|
params.Limit = opts.Limit
|
||||||
|
if params.Limit == 0 {
|
||||||
|
params.Limit = 100 // default limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: model.QueryParam doesn't have Offset, use Page instead
|
||||||
|
if opts.Offset > 0 && opts.Limit > 0 {
|
||||||
|
params.Page = (opts.Offset / opts.Limit) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OrderBy != "" {
|
||||||
|
// Parse OrderBy: "column desc" or "column asc" or just "column"
|
||||||
|
parts := splitOrderBy(opts.OrderBy)
|
||||||
|
params.Orders = []model.QueryOrder{{Column: parts[0], Option: parts[1]}}
|
||||||
|
} else {
|
||||||
|
params.Orders = []model.QueryOrder{{Column: "start_time", Option: "desc"}}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.Limit = 100
|
||||||
|
params.Orders = []model.QueryOrder{{Column: "start_time", Option: "desc"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
params.Wheres = wheres
|
||||||
|
|
||||||
|
rows, err := mod.Get(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list execution records: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]*ExecutionRecord, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
record, err := s.mapToRecord(row)
|
||||||
|
if err != nil {
|
||||||
|
continue // skip invalid records
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePhase updates the current phase and its data
|
||||||
|
func (s *ExecutionStore) UpdatePhase(ctx context.Context, executionID string, phase types.Phase, data interface{}) error {
|
||||||
|
mod := model.Select(s.modelID)
|
||||||
|
if mod == nil {
|
||||||
|
return fmt.Errorf("model %s not found", s.modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData := map[string]interface{}{
|
||||||
|
"phase": string(phase),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the appropriate phase output field
|
||||||
|
switch phase {
|
||||||
|
case types.PhaseInspiration:
|
||||||
|
if data != nil {
|
||||||
|
updateData["inspiration"] = data
|
||||||
|
}
|
||||||
|
case types.PhaseGoals:
|
||||||
|
if data != nil {
|
||||||
|
updateData["goals"] = data
|
||||||
|
}
|
||||||
|
case types.PhaseTasks:
|
||||||
|
if data != nil {
|
||||||
|
updateData["tasks"] = data
|
||||||
|
}
|
||||||
|
case types.PhaseRun:
|
||||||
|
if data != nil {
|
||||||
|
updateData["results"] = data
|
||||||
|
}
|
||||||
|
case types.PhaseDelivery:
|
||||||
|
if data != nil {
|
||||||
|
updateData["delivery"] = data
|
||||||
|
}
|
||||||
|
case types.PhaseLearning:
|
||||||
|
if data != nil {
|
||||||
|
updateData["learning"] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := mod.UpdateWhere(
|
||||||
|
model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "execution_id", Value: executionID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateData,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update phase: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates the execution status
|
||||||
|
func (s *ExecutionStore) UpdateStatus(ctx context.Context, executionID string, status types.ExecStatus, errorMsg string) error {
|
||||||
|
mod := model.Select(s.modelID)
|
||||||
|
if mod == nil {
|
||||||
|
return fmt.Errorf("model %s not found", s.modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData := map[string]interface{}{
|
||||||
|
"status": string(status),
|
||||||
|
}
|
||||||
|
|
||||||
|
if errorMsg != "" {
|
||||||
|
updateData["error"] = errorMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set end_time for terminal states
|
||||||
|
if status == types.ExecCompleted || status == types.ExecFailed || status == types.ExecCancelled {
|
||||||
|
now := time.Now()
|
||||||
|
updateData["end_time"] = now
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := mod.UpdateWhere(
|
||||||
|
model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "execution_id", Value: executionID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateData,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCurrent updates the current executing state
|
||||||
|
func (s *ExecutionStore) UpdateCurrent(ctx context.Context, executionID string, current *CurrentState) error {
|
||||||
|
mod := model.Select(s.modelID)
|
||||||
|
if mod == nil {
|
||||||
|
return fmt.Errorf("model %s not found", s.modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData := map[string]interface{}{
|
||||||
|
"current": current,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := mod.UpdateWhere(
|
||||||
|
model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "execution_id", Value: executionID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateData,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update current state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an execution record by execution_id
|
||||||
|
func (s *ExecutionStore) Delete(ctx context.Context, executionID string) error {
|
||||||
|
mod := model.Select(s.modelID)
|
||||||
|
if mod == nil {
|
||||||
|
return fmt.Errorf("model %s not found", s.modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := mod.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "execution_id", Value: executionID},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete execution record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordToMap converts ExecutionRecord to map for model operations
|
||||||
|
func (s *ExecutionStore) recordToMap(record *ExecutionRecord) map[string]interface{} {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"execution_id": record.ExecutionID,
|
||||||
|
"member_id": record.MemberID,
|
||||||
|
"team_id": record.TeamID,
|
||||||
|
"trigger_type": string(record.TriggerType),
|
||||||
|
"status": string(record.Status),
|
||||||
|
"phase": string(record.Phase),
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.JobID != "" {
|
||||||
|
data["job_id"] = record.JobID
|
||||||
|
}
|
||||||
|
if record.Error != "" {
|
||||||
|
data["error"] = record.Error
|
||||||
|
}
|
||||||
|
if record.Current != nil {
|
||||||
|
data["current"] = record.Current
|
||||||
|
}
|
||||||
|
if record.Input != nil {
|
||||||
|
data["input"] = record.Input
|
||||||
|
}
|
||||||
|
if record.Inspiration != nil {
|
||||||
|
data["inspiration"] = record.Inspiration
|
||||||
|
}
|
||||||
|
if record.Goals != nil {
|
||||||
|
data["goals"] = record.Goals
|
||||||
|
}
|
||||||
|
if record.Tasks != nil {
|
||||||
|
data["tasks"] = record.Tasks
|
||||||
|
}
|
||||||
|
if record.Results != nil {
|
||||||
|
data["results"] = record.Results
|
||||||
|
}
|
||||||
|
if record.Delivery != nil {
|
||||||
|
data["delivery"] = record.Delivery
|
||||||
|
}
|
||||||
|
if record.Learning != nil {
|
||||||
|
data["learning"] = record.Learning
|
||||||
|
}
|
||||||
|
if record.StartTime != nil {
|
||||||
|
data["start_time"] = *record.StartTime
|
||||||
|
}
|
||||||
|
if record.EndTime != nil {
|
||||||
|
data["end_time"] = *record.EndTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapToRecord converts a model row to ExecutionRecord
|
||||||
|
func (s *ExecutionStore) mapToRecord(row map[string]interface{}) (*ExecutionRecord, error) {
|
||||||
|
record := &ExecutionRecord{}
|
||||||
|
|
||||||
|
// Basic fields
|
||||||
|
if v, ok := row["id"]; ok {
|
||||||
|
switch id := v.(type) {
|
||||||
|
case float64:
|
||||||
|
record.ID = int64(id)
|
||||||
|
case int64:
|
||||||
|
record.ID = id
|
||||||
|
case int:
|
||||||
|
record.ID = int64(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := row["execution_id"].(string); ok {
|
||||||
|
record.ExecutionID = v
|
||||||
|
}
|
||||||
|
if v, ok := row["member_id"].(string); ok {
|
||||||
|
record.MemberID = v
|
||||||
|
}
|
||||||
|
if v, ok := row["team_id"].(string); ok {
|
||||||
|
record.TeamID = v
|
||||||
|
}
|
||||||
|
if v, ok := row["job_id"].(string); ok {
|
||||||
|
record.JobID = v
|
||||||
|
}
|
||||||
|
if v, ok := row["trigger_type"].(string); ok {
|
||||||
|
record.TriggerType = types.TriggerType(v)
|
||||||
|
}
|
||||||
|
if v, ok := row["status"].(string); ok {
|
||||||
|
record.Status = types.ExecStatus(v)
|
||||||
|
}
|
||||||
|
if v, ok := row["phase"].(string); ok {
|
||||||
|
record.Phase = types.Phase(v)
|
||||||
|
}
|
||||||
|
if v, ok := row["error"].(string); ok {
|
||||||
|
record.Error = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON fields - need to unmarshal
|
||||||
|
if v := row["current"]; v != nil {
|
||||||
|
record.Current = s.parseCurrentState(v)
|
||||||
|
}
|
||||||
|
if v := row["input"]; v != nil {
|
||||||
|
record.Input = s.parseTriggerInput(v)
|
||||||
|
}
|
||||||
|
if v := row["inspiration"]; v != nil {
|
||||||
|
record.Inspiration = s.parseInspirationReport(v)
|
||||||
|
}
|
||||||
|
if v := row["goals"]; v != nil {
|
||||||
|
record.Goals = s.parseGoals(v)
|
||||||
|
}
|
||||||
|
if v := row["tasks"]; v != nil {
|
||||||
|
record.Tasks = s.parseTasks(v)
|
||||||
|
}
|
||||||
|
if v := row["results"]; v != nil {
|
||||||
|
record.Results = s.parseResults(v)
|
||||||
|
}
|
||||||
|
if v := row["delivery"]; v != nil {
|
||||||
|
record.Delivery = s.parseDeliveryResult(v)
|
||||||
|
}
|
||||||
|
if v := row["learning"]; v != nil {
|
||||||
|
record.Learning = s.parseLearningEntries(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
if v := row["start_time"]; v != nil {
|
||||||
|
record.StartTime = s.parseTime(v)
|
||||||
|
}
|
||||||
|
if v := row["end_time"]; v != nil {
|
||||||
|
record.EndTime = s.parseTime(v)
|
||||||
|
}
|
||||||
|
if v := row["created_at"]; v != nil {
|
||||||
|
record.CreatedAt = s.parseTime(v)
|
||||||
|
}
|
||||||
|
if v := row["updated_at"]; v != nil {
|
||||||
|
record.UpdatedAt = s.parseTime(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for parsing JSON fields
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseCurrentState(v interface{}) *CurrentState {
|
||||||
|
data, err := s.toJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var state CurrentState
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseTriggerInput(v interface{}) *types.TriggerInput {
|
||||||
|
data, err := s.toJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var input types.TriggerInput
|
||||||
|
if err := json.Unmarshal(data, &input); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &input
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseInspirationReport(v interface{}) *types.InspirationReport {
|
||||||
|
data, err := s.toJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var report types.InspirationReport
|
||||||
|
if err := json.Unmarshal(data, &report); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &report
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseGoals(v interface{}) *types.Goals {
|
||||||
|
data, err := s.toJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var goals types.Goals
|
||||||
|
if err := json.Unmarshal(data, &goals); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &goals
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseTasks(v interface{}) []types.Task {
|
||||||
|
data, err := s.toJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var tasks []types.Task
|
||||||
|
if err := json.Unmarshal(data, &tasks); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseResults(v interface{}) []types.TaskResult {
|
||||||
|
data, err := s.toJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var results []types.TaskResult
|
||||||
|
if err := json.Unmarshal(data, &results); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseDeliveryResult(v interface{}) *types.DeliveryResult {
|
||||||
|
data, err := s.toJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var result types.DeliveryResult
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseLearningEntries(v interface{}) []types.LearningEntry {
|
||||||
|
data, err := s.toJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var entries []types.LearningEntry
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) toJSON(v interface{}) ([]byte, error) {
|
||||||
|
switch data := v.(type) {
|
||||||
|
case []byte:
|
||||||
|
return data, nil
|
||||||
|
case string:
|
||||||
|
return []byte(data), nil
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
return json.Marshal(data)
|
||||||
|
default:
|
||||||
|
return json.Marshal(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitOrderBy parses "column desc" or "column asc" or just "column"
|
||||||
|
// Returns [column, option] where option defaults to "desc"
|
||||||
|
func splitOrderBy(orderBy string) [2]string {
|
||||||
|
parts := [2]string{"", "desc"}
|
||||||
|
if orderBy == "" {
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by space
|
||||||
|
for i, c := range orderBy {
|
||||||
|
if c == ' ' {
|
||||||
|
parts[0] = orderBy[:i]
|
||||||
|
rest := orderBy[i+1:]
|
||||||
|
if rest == "asc" || rest == "ASC" {
|
||||||
|
parts[1] = "asc"
|
||||||
|
} else if rest == "desc" || rest == "DESC" {
|
||||||
|
parts[1] = "desc"
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No space found, just column name
|
||||||
|
parts[0] = orderBy
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecutionStore) parseTime(v interface{}) *time.Time {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return &t
|
||||||
|
case *time.Time:
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
// Try parsing common time formats
|
||||||
|
formats := []string{
|
||||||
|
time.RFC3339,
|
||||||
|
time.RFC3339Nano,
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05Z",
|
||||||
|
}
|
||||||
|
for _, format := range formats {
|
||||||
|
if parsed, err := time.Parse(format, t); err == nil {
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromExecution creates an ExecutionRecord from a runtime Execution
|
||||||
|
func FromExecution(exec *types.Execution) *ExecutionRecord {
|
||||||
|
record := &ExecutionRecord{
|
||||||
|
ExecutionID: exec.ID,
|
||||||
|
MemberID: exec.MemberID,
|
||||||
|
TeamID: exec.TeamID,
|
||||||
|
JobID: exec.JobID,
|
||||||
|
TriggerType: exec.TriggerType,
|
||||||
|
Status: exec.Status,
|
||||||
|
Phase: exec.Phase,
|
||||||
|
Error: exec.Error,
|
||||||
|
Input: exec.Input,
|
||||||
|
Inspiration: exec.Inspiration,
|
||||||
|
Goals: exec.Goals,
|
||||||
|
Tasks: exec.Tasks,
|
||||||
|
Results: exec.Results,
|
||||||
|
Delivery: exec.Delivery,
|
||||||
|
Learning: exec.Learning,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert timestamps
|
||||||
|
if !exec.StartTime.IsZero() {
|
||||||
|
record.StartTime = &exec.StartTime
|
||||||
|
}
|
||||||
|
if exec.EndTime != nil {
|
||||||
|
record.EndTime = exec.EndTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert CurrentState
|
||||||
|
if exec.Current != nil {
|
||||||
|
record.Current = &CurrentState{
|
||||||
|
TaskIndex: exec.Current.TaskIndex,
|
||||||
|
Progress: exec.Current.Progress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToExecution converts an ExecutionRecord to a runtime Execution
|
||||||
|
func (r *ExecutionRecord) ToExecution() *types.Execution {
|
||||||
|
exec := &types.Execution{
|
||||||
|
ID: r.ExecutionID,
|
||||||
|
MemberID: r.MemberID,
|
||||||
|
TeamID: r.TeamID,
|
||||||
|
JobID: r.JobID,
|
||||||
|
TriggerType: r.TriggerType,
|
||||||
|
Status: r.Status,
|
||||||
|
Phase: r.Phase,
|
||||||
|
Error: r.Error,
|
||||||
|
Input: r.Input,
|
||||||
|
Inspiration: r.Inspiration,
|
||||||
|
Goals: r.Goals,
|
||||||
|
Tasks: r.Tasks,
|
||||||
|
Results: r.Results,
|
||||||
|
Delivery: r.Delivery,
|
||||||
|
Learning: r.Learning,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert timestamps
|
||||||
|
if r.StartTime != nil {
|
||||||
|
exec.StartTime = *r.StartTime
|
||||||
|
}
|
||||||
|
if r.EndTime != nil {
|
||||||
|
exec.EndTime = r.EndTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert CurrentState
|
||||||
|
if r.Current != nil {
|
||||||
|
exec.Current = &types.CurrentState{
|
||||||
|
TaskIndex: r.Current.TaskIndex,
|
||||||
|
Progress: r.Current.Progress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exec
|
||||||
|
}
|
||||||
768
agent/robot/store/execution_test.go
Normal file
768
agent/robot/store/execution_test.go
Normal file
|
|
@ -0,0 +1,768 @@
|
||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/store"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExecutionStoreSave tests creating and updating execution records
|
||||||
|
func TestExecutionStoreSave(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Clean up any existing test data
|
||||||
|
cleanupTestExecutions(t)
|
||||||
|
defer cleanupTestExecutions(t)
|
||||||
|
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("creates_new_execution_record", func(t *testing.T) {
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_save_001",
|
||||||
|
MemberID: "member_test_001",
|
||||||
|
TeamID: "team_test_001",
|
||||||
|
JobID: "job_test_001",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecPending,
|
||||||
|
Phase: types.PhaseInspiration,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify it was created
|
||||||
|
saved, err := s.Get(ctx, "exec_test_save_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, saved)
|
||||||
|
|
||||||
|
assert.Equal(t, "exec_test_save_001", saved.ExecutionID)
|
||||||
|
assert.Equal(t, "member_test_001", saved.MemberID)
|
||||||
|
assert.Equal(t, "team_test_001", saved.TeamID)
|
||||||
|
assert.Equal(t, "job_test_001", saved.JobID)
|
||||||
|
assert.Equal(t, types.TriggerClock, saved.TriggerType)
|
||||||
|
assert.Equal(t, types.ExecPending, saved.Status)
|
||||||
|
assert.Equal(t, types.PhaseInspiration, saved.Phase)
|
||||||
|
assert.NotNil(t, saved.StartTime)
|
||||||
|
assert.NotNil(t, saved.CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_existing_execution_record", func(t *testing.T) {
|
||||||
|
// First create a record
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_save_002",
|
||||||
|
MemberID: "member_test_002",
|
||||||
|
TeamID: "team_test_002",
|
||||||
|
TriggerType: types.TriggerHuman,
|
||||||
|
Status: types.ExecPending,
|
||||||
|
Phase: types.PhaseInspiration,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the record
|
||||||
|
record.Status = types.ExecRunning
|
||||||
|
record.Phase = types.PhaseGoals
|
||||||
|
record.Goals = &types.Goals{Content: "Test goals content"}
|
||||||
|
|
||||||
|
err = s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the update
|
||||||
|
saved, err := s.Get(ctx, "exec_test_save_002")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, saved)
|
||||||
|
|
||||||
|
assert.Equal(t, types.ExecRunning, saved.Status)
|
||||||
|
assert.Equal(t, types.PhaseGoals, saved.Phase)
|
||||||
|
assert.NotNil(t, saved.Goals)
|
||||||
|
assert.Equal(t, "Test goals content", saved.Goals.Content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutionStoreGet tests retrieving execution records
|
||||||
|
func TestExecutionStoreGet(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
cleanupTestExecutions(t)
|
||||||
|
defer cleanupTestExecutions(t)
|
||||||
|
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test record with all fields populated
|
||||||
|
setupTestExecution(t, s, ctx)
|
||||||
|
|
||||||
|
t.Run("returns_existing_record", func(t *testing.T) {
|
||||||
|
record, err := s.Get(ctx, "exec_test_get_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, record)
|
||||||
|
|
||||||
|
assert.Equal(t, "exec_test_get_001", record.ExecutionID)
|
||||||
|
assert.Equal(t, "member_test_get", record.MemberID)
|
||||||
|
assert.Equal(t, "team_test_get", record.TeamID)
|
||||||
|
assert.Equal(t, types.TriggerClock, record.TriggerType)
|
||||||
|
assert.Equal(t, types.ExecCompleted, record.Status)
|
||||||
|
assert.Equal(t, types.PhaseDelivery, record.Phase)
|
||||||
|
|
||||||
|
// Verify phase outputs
|
||||||
|
assert.NotNil(t, record.Inspiration)
|
||||||
|
assert.Equal(t, "Test inspiration content", record.Inspiration.Content)
|
||||||
|
assert.NotNil(t, record.Goals)
|
||||||
|
assert.Equal(t, "Test goals content", record.Goals.Content)
|
||||||
|
assert.Len(t, record.Tasks, 2)
|
||||||
|
assert.Equal(t, "task_001", record.Tasks[0].ID)
|
||||||
|
assert.Len(t, record.Results, 2)
|
||||||
|
assert.True(t, record.Results[0].Success)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns_nil_for_non_existent_record", func(t *testing.T) {
|
||||||
|
record, err := s.Get(ctx, "exec_non_existent")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, record)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutionStoreList tests listing execution records with filters
|
||||||
|
func TestExecutionStoreList(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
cleanupTestExecutions(t)
|
||||||
|
defer cleanupTestExecutions(t)
|
||||||
|
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create multiple test records
|
||||||
|
setupTestExecutionsForList(t, s, ctx)
|
||||||
|
|
||||||
|
t.Run("lists_all_records_without_filters", func(t *testing.T) {
|
||||||
|
records, err := s.List(ctx, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, len(records), 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("filters_by_member_id", func(t *testing.T) {
|
||||||
|
records, err := s.List(ctx, &store.ListOptions{
|
||||||
|
MemberID: "member_list_001",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
assert.Equal(t, "member_list_001", r.MemberID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("filters_by_team_id", func(t *testing.T) {
|
||||||
|
records, err := s.List(ctx, &store.ListOptions{
|
||||||
|
TeamID: "team_list_001",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
assert.Equal(t, "team_list_001", r.TeamID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("filters_by_status", func(t *testing.T) {
|
||||||
|
records, err := s.List(ctx, &store.ListOptions{
|
||||||
|
Status: types.ExecCompleted,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, len(records), 2)
|
||||||
|
for _, r := range records {
|
||||||
|
assert.Equal(t, types.ExecCompleted, r.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("filters_by_trigger_type", func(t *testing.T) {
|
||||||
|
records, err := s.List(ctx, &store.ListOptions{
|
||||||
|
TriggerType: types.TriggerHuman,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, len(records), 1)
|
||||||
|
for _, r := range records {
|
||||||
|
assert.Equal(t, types.TriggerHuman, r.TriggerType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("respects_limit", func(t *testing.T) {
|
||||||
|
records, err := s.List(ctx, &store.ListOptions{
|
||||||
|
Limit: 2,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(records))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("combines_multiple_filters", func(t *testing.T) {
|
||||||
|
records, err := s.List(ctx, &store.ListOptions{
|
||||||
|
TeamID: "team_list_001",
|
||||||
|
Status: types.ExecCompleted,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
assert.Equal(t, "team_list_001", r.TeamID)
|
||||||
|
assert.Equal(t, types.ExecCompleted, r.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutionStoreUpdatePhase tests updating phase and phase data
|
||||||
|
func TestExecutionStoreUpdatePhase(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
cleanupTestExecutions(t)
|
||||||
|
defer cleanupTestExecutions(t)
|
||||||
|
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a base record
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_phase_001",
|
||||||
|
MemberID: "member_phase_001",
|
||||||
|
TeamID: "team_phase_001",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecRunning,
|
||||||
|
Phase: types.PhaseInspiration,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("updates_inspiration_phase", func(t *testing.T) {
|
||||||
|
inspiration := &types.InspirationReport{
|
||||||
|
Content: "Updated inspiration content",
|
||||||
|
}
|
||||||
|
err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseInspiration, inspiration)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_phase_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.PhaseInspiration, saved.Phase)
|
||||||
|
assert.NotNil(t, saved.Inspiration)
|
||||||
|
assert.Equal(t, "Updated inspiration content", saved.Inspiration.Content)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_goals_phase", func(t *testing.T) {
|
||||||
|
goals := &types.Goals{
|
||||||
|
Content: "Updated goals content",
|
||||||
|
}
|
||||||
|
err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseGoals, goals)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_phase_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.PhaseGoals, saved.Phase)
|
||||||
|
assert.NotNil(t, saved.Goals)
|
||||||
|
assert.Equal(t, "Updated goals content", saved.Goals.Content)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_tasks_phase", func(t *testing.T) {
|
||||||
|
tasks := []types.Task{
|
||||||
|
{ID: "task_phase_001", ExecutorType: types.ExecutorAssistant},
|
||||||
|
{ID: "task_phase_002", ExecutorType: types.ExecutorProcess},
|
||||||
|
}
|
||||||
|
err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseTasks, tasks)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_phase_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.PhaseTasks, saved.Phase)
|
||||||
|
assert.Len(t, saved.Tasks, 2)
|
||||||
|
assert.Equal(t, "task_phase_001", saved.Tasks[0].ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_run_phase", func(t *testing.T) {
|
||||||
|
results := []types.TaskResult{
|
||||||
|
{TaskID: "task_phase_001", Success: true, Output: "Result 1"},
|
||||||
|
{TaskID: "task_phase_002", Success: false, Error: "Failed"},
|
||||||
|
}
|
||||||
|
err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseRun, results)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_phase_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.PhaseRun, saved.Phase)
|
||||||
|
assert.Len(t, saved.Results, 2)
|
||||||
|
assert.True(t, saved.Results[0].Success)
|
||||||
|
assert.False(t, saved.Results[1].Success)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_delivery_phase", func(t *testing.T) {
|
||||||
|
delivery := &types.DeliveryResult{
|
||||||
|
Success: true,
|
||||||
|
}
|
||||||
|
err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseDelivery, delivery)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_phase_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.PhaseDelivery, saved.Phase)
|
||||||
|
assert.NotNil(t, saved.Delivery)
|
||||||
|
assert.True(t, saved.Delivery.Success)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_learning_phase", func(t *testing.T) {
|
||||||
|
learning := []types.LearningEntry{
|
||||||
|
{Type: types.LearnExecution, Content: "Learned something"},
|
||||||
|
}
|
||||||
|
err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseLearning, learning)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_phase_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.PhaseLearning, saved.Phase)
|
||||||
|
assert.Len(t, saved.Learning, 1)
|
||||||
|
assert.Equal(t, "Learned something", saved.Learning[0].Content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutionStoreUpdateStatus tests updating execution status
|
||||||
|
func TestExecutionStoreUpdateStatus(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
cleanupTestExecutions(t)
|
||||||
|
defer cleanupTestExecutions(t)
|
||||||
|
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("updates_status_to_running", func(t *testing.T) {
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_status_001",
|
||||||
|
MemberID: "member_status_001",
|
||||||
|
TeamID: "team_status_001",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecPending,
|
||||||
|
Phase: types.PhaseInspiration,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = s.UpdateStatus(ctx, "exec_test_status_001", types.ExecRunning, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_status_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.ExecRunning, saved.Status)
|
||||||
|
assert.Nil(t, saved.EndTime) // Should not set end_time for running
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_status_to_completed_with_end_time", func(t *testing.T) {
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_status_002",
|
||||||
|
MemberID: "member_status_002",
|
||||||
|
TeamID: "team_status_002",
|
||||||
|
TriggerType: types.TriggerHuman,
|
||||||
|
Status: types.ExecRunning,
|
||||||
|
Phase: types.PhaseDelivery,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = s.UpdateStatus(ctx, "exec_test_status_002", types.ExecCompleted, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_status_002")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.ExecCompleted, saved.Status)
|
||||||
|
assert.NotNil(t, saved.EndTime) // Should set end_time for completed
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_status_to_failed_with_error", func(t *testing.T) {
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_status_003",
|
||||||
|
MemberID: "member_status_003",
|
||||||
|
TeamID: "team_status_003",
|
||||||
|
TriggerType: types.TriggerEvent,
|
||||||
|
Status: types.ExecRunning,
|
||||||
|
Phase: types.PhaseRun,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = s.UpdateStatus(ctx, "exec_test_status_003", types.ExecFailed, "Task execution failed: timeout")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_status_003")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.ExecFailed, saved.Status)
|
||||||
|
assert.Equal(t, "Task execution failed: timeout", saved.Error)
|
||||||
|
assert.NotNil(t, saved.EndTime) // Should set end_time for failed
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates_status_to_cancelled", func(t *testing.T) {
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_status_004",
|
||||||
|
MemberID: "member_status_004",
|
||||||
|
TeamID: "team_status_004",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecRunning,
|
||||||
|
Phase: types.PhaseTasks,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = s.UpdateStatus(ctx, "exec_test_status_004", types.ExecCancelled, "User cancelled")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_status_004")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, types.ExecCancelled, saved.Status)
|
||||||
|
assert.Equal(t, "User cancelled", saved.Error)
|
||||||
|
assert.NotNil(t, saved.EndTime) // Should set end_time for cancelled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutionStoreUpdateCurrent tests updating current state
|
||||||
|
func TestExecutionStoreUpdateCurrent(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
cleanupTestExecutions(t)
|
||||||
|
defer cleanupTestExecutions(t)
|
||||||
|
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a base record
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_current_001",
|
||||||
|
MemberID: "member_current_001",
|
||||||
|
TeamID: "team_current_001",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecRunning,
|
||||||
|
Phase: types.PhaseRun,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("updates_current_state", func(t *testing.T) {
|
||||||
|
current := &store.CurrentState{
|
||||||
|
TaskIndex: 2,
|
||||||
|
Progress: "3/5 tasks completed",
|
||||||
|
}
|
||||||
|
err := s.UpdateCurrent(ctx, "exec_test_current_001", current)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saved, err := s.Get(ctx, "exec_test_current_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, saved.Current)
|
||||||
|
assert.Equal(t, 2, saved.Current.TaskIndex)
|
||||||
|
assert.Equal(t, "3/5 tasks completed", saved.Current.Progress)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutionStoreDelete tests deleting execution records
|
||||||
|
func TestExecutionStoreDelete(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
cleanupTestExecutions(t)
|
||||||
|
defer cleanupTestExecutions(t)
|
||||||
|
|
||||||
|
s := store.NewExecutionStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("deletes_existing_record", func(t *testing.T) {
|
||||||
|
// Create a record
|
||||||
|
startTime := time.Now()
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_delete_001",
|
||||||
|
MemberID: "member_delete_001",
|
||||||
|
TeamID: "team_delete_001",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecCompleted,
|
||||||
|
Phase: types.PhaseDelivery,
|
||||||
|
StartTime: &startTime,
|
||||||
|
}
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
saved, err := s.Get(ctx, "exec_test_delete_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, saved)
|
||||||
|
|
||||||
|
// Delete it
|
||||||
|
err = s.Delete(ctx, "exec_test_delete_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
saved, err = s.Get(ctx, "exec_test_delete_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, saved)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no_error_for_non_existent_record", func(t *testing.T) {
|
||||||
|
err := s.Delete(ctx, "exec_non_existent")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutionRecordConversion tests conversion between ExecutionRecord and Execution
|
||||||
|
func TestExecutionRecordConversion(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
t.Run("converts_from_execution", func(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
endTime := now.Add(time.Hour)
|
||||||
|
exec := &types.Execution{
|
||||||
|
ID: "exec_convert_001",
|
||||||
|
MemberID: "member_convert_001",
|
||||||
|
TeamID: "team_convert_001",
|
||||||
|
JobID: "job_convert_001",
|
||||||
|
TriggerType: types.TriggerHuman,
|
||||||
|
Status: types.ExecCompleted,
|
||||||
|
Phase: types.PhaseDelivery,
|
||||||
|
StartTime: now,
|
||||||
|
EndTime: &endTime,
|
||||||
|
Error: "",
|
||||||
|
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
|
||||||
|
Goals: &types.Goals{Content: "Test goals"},
|
||||||
|
Tasks: []types.Task{
|
||||||
|
{ID: "task_001", ExecutorType: types.ExecutorAssistant},
|
||||||
|
},
|
||||||
|
Results: []types.TaskResult{
|
||||||
|
{TaskID: "task_001", Success: true},
|
||||||
|
},
|
||||||
|
Current: &types.CurrentState{
|
||||||
|
TaskIndex: 1,
|
||||||
|
Progress: "1/1 tasks",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
record := store.FromExecution(exec)
|
||||||
|
|
||||||
|
assert.Equal(t, "exec_convert_001", record.ExecutionID)
|
||||||
|
assert.Equal(t, "member_convert_001", record.MemberID)
|
||||||
|
assert.Equal(t, "team_convert_001", record.TeamID)
|
||||||
|
assert.Equal(t, "job_convert_001", record.JobID)
|
||||||
|
assert.Equal(t, types.TriggerHuman, record.TriggerType)
|
||||||
|
assert.Equal(t, types.ExecCompleted, record.Status)
|
||||||
|
assert.Equal(t, types.PhaseDelivery, record.Phase)
|
||||||
|
assert.NotNil(t, record.StartTime)
|
||||||
|
assert.NotNil(t, record.EndTime)
|
||||||
|
assert.NotNil(t, record.Inspiration)
|
||||||
|
assert.NotNil(t, record.Goals)
|
||||||
|
assert.Len(t, record.Tasks, 1)
|
||||||
|
assert.Len(t, record.Results, 1)
|
||||||
|
assert.NotNil(t, record.Current)
|
||||||
|
assert.Equal(t, 1, record.Current.TaskIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("converts_to_execution", func(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
endTime := now.Add(time.Hour)
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_convert_002",
|
||||||
|
MemberID: "member_convert_002",
|
||||||
|
TeamID: "team_convert_002",
|
||||||
|
JobID: "job_convert_002",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecRunning,
|
||||||
|
Phase: types.PhaseRun,
|
||||||
|
StartTime: &now,
|
||||||
|
EndTime: &endTime,
|
||||||
|
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
|
||||||
|
Goals: &types.Goals{Content: "Test goals"},
|
||||||
|
Tasks: []types.Task{
|
||||||
|
{ID: "task_002", ExecutorType: types.ExecutorProcess},
|
||||||
|
},
|
||||||
|
Results: []types.TaskResult{
|
||||||
|
{TaskID: "task_002", Success: false, Error: "Failed"},
|
||||||
|
},
|
||||||
|
Current: &store.CurrentState{
|
||||||
|
TaskIndex: 0,
|
||||||
|
Progress: "0/1 tasks",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
exec := record.ToExecution()
|
||||||
|
|
||||||
|
assert.Equal(t, "exec_convert_002", exec.ID)
|
||||||
|
assert.Equal(t, "member_convert_002", exec.MemberID)
|
||||||
|
assert.Equal(t, "team_convert_002", exec.TeamID)
|
||||||
|
assert.Equal(t, "job_convert_002", exec.JobID)
|
||||||
|
assert.Equal(t, types.TriggerClock, exec.TriggerType)
|
||||||
|
assert.Equal(t, types.ExecRunning, exec.Status)
|
||||||
|
assert.Equal(t, types.PhaseRun, exec.Phase)
|
||||||
|
assert.NotNil(t, exec.Inspiration)
|
||||||
|
assert.NotNil(t, exec.Goals)
|
||||||
|
assert.Len(t, exec.Tasks, 1)
|
||||||
|
assert.Len(t, exec.Results, 1)
|
||||||
|
assert.NotNil(t, exec.Current)
|
||||||
|
assert.Equal(t, 0, exec.Current.TaskIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func cleanupTestExecutions(t *testing.T) {
|
||||||
|
mod := model.Select("__yao.agent.execution")
|
||||||
|
if mod == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all test execution records
|
||||||
|
_, err := mod.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "execution_id", OP: "like", Value: "exec_test_%"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Warning: failed to cleanup test executions: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestExecution(t *testing.T, s *store.ExecutionStore, ctx context.Context) {
|
||||||
|
startTime := time.Now().Add(-time.Hour)
|
||||||
|
endTime := time.Now()
|
||||||
|
|
||||||
|
record := &store.ExecutionRecord{
|
||||||
|
ExecutionID: "exec_test_get_001",
|
||||||
|
MemberID: "member_test_get",
|
||||||
|
TeamID: "team_test_get",
|
||||||
|
JobID: "job_test_get",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecCompleted,
|
||||||
|
Phase: types.PhaseDelivery,
|
||||||
|
StartTime: &startTime,
|
||||||
|
EndTime: &endTime,
|
||||||
|
Inspiration: &types.InspirationReport{
|
||||||
|
Content: "Test inspiration content",
|
||||||
|
},
|
||||||
|
Goals: &types.Goals{
|
||||||
|
Content: "Test goals content",
|
||||||
|
},
|
||||||
|
Tasks: []types.Task{
|
||||||
|
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted},
|
||||||
|
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskCompleted},
|
||||||
|
},
|
||||||
|
Results: []types.TaskResult{
|
||||||
|
{TaskID: "task_001", Success: true, Output: "Result 1"},
|
||||||
|
{TaskID: "task_002", Success: true, Output: "Result 2"},
|
||||||
|
},
|
||||||
|
Delivery: &types.DeliveryResult{
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
Learning: []types.LearningEntry{
|
||||||
|
{Type: types.LearnExecution, Content: "Test learning"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestExecutionsForList(t *testing.T, s *store.ExecutionStore, ctx context.Context) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
records := []*store.ExecutionRecord{
|
||||||
|
{
|
||||||
|
ExecutionID: "exec_test_list_001",
|
||||||
|
MemberID: "member_list_001",
|
||||||
|
TeamID: "team_list_001",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecCompleted,
|
||||||
|
Phase: types.PhaseDelivery,
|
||||||
|
StartTime: &startTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ExecutionID: "exec_test_list_002",
|
||||||
|
MemberID: "member_list_001",
|
||||||
|
TeamID: "team_list_001",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecCompleted,
|
||||||
|
Phase: types.PhaseDelivery,
|
||||||
|
StartTime: &startTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ExecutionID: "exec_test_list_003",
|
||||||
|
MemberID: "member_list_002",
|
||||||
|
TeamID: "team_list_001",
|
||||||
|
TriggerType: types.TriggerHuman,
|
||||||
|
Status: types.ExecRunning,
|
||||||
|
Phase: types.PhaseRun,
|
||||||
|
StartTime: &startTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ExecutionID: "exec_test_list_004",
|
||||||
|
MemberID: "member_list_002",
|
||||||
|
TeamID: "team_list_002",
|
||||||
|
TriggerType: types.TriggerEvent,
|
||||||
|
Status: types.ExecFailed,
|
||||||
|
Phase: types.PhaseRun,
|
||||||
|
StartTime: &startTime,
|
||||||
|
Error: "Test error",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
err := s.Save(ctx, record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ type Config struct {
|
||||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||||
Resources *Resources `json:"resources,omitempty"`
|
Resources *Resources `json:"resources,omitempty"`
|
||||||
Delivery *Delivery `json:"delivery,omitempty"`
|
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // delivery preferences (see robot.go)
|
||||||
Events []Event `json:"events,omitempty"`
|
Events []Event `json:"events,omitempty"`
|
||||||
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
|
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
|
||||||
}
|
}
|
||||||
|
|
@ -224,12 +224,6 @@ type MCPConfig struct {
|
||||||
Tools []string `json:"tools,omitempty"` // empty = all
|
Tools []string `json:"tools,omitempty"` // empty = all
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery - output delivery
|
|
||||||
type Delivery struct {
|
|
||||||
Type DeliveryType `json:"type"`
|
|
||||||
Opts map[string]interface{} `json:"opts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event - event trigger config
|
// Event - event trigger config
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type EventSource `json:"type"` // webhook | database
|
Type EventSource `json:"type"` // webhook | database
|
||||||
|
|
|
||||||
35
agent/robot/types/config_global.go
Normal file
35
agent/robot/types/config_global.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package types
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Global configuration for robot agent
|
||||||
|
// These values can be set during agent initialization
|
||||||
|
|
||||||
|
var (
|
||||||
|
// defaultEmailChannel - default messenger channel name for sending emails
|
||||||
|
// Can be configured via SetDefaultEmailChannel()
|
||||||
|
// Default: "email" (maps to messengers/channels.yao configuration)
|
||||||
|
defaultEmailChannel = "email"
|
||||||
|
|
||||||
|
// configMu protects global configuration
|
||||||
|
configMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultEmailChannel returns the default email channel name
|
||||||
|
func DefaultEmailChannel() string {
|
||||||
|
configMu.RLock()
|
||||||
|
defer configMu.RUnlock()
|
||||||
|
return defaultEmailChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultEmailChannel sets the default messenger channel for email delivery
|
||||||
|
// This should be called during agent initialization
|
||||||
|
// The channel name must match a channel defined in messengers/channels.yao
|
||||||
|
func SetDefaultEmailChannel(channel string) {
|
||||||
|
if channel == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configMu.Lock()
|
||||||
|
defer configMu.Unlock()
|
||||||
|
defaultEmailChannel = channel
|
||||||
|
}
|
||||||
|
|
@ -111,10 +111,10 @@ type DeliveryType string
|
||||||
|
|
||||||
// DeliveryType constants define the output delivery types
|
// DeliveryType constants define the output delivery types
|
||||||
const (
|
const (
|
||||||
DeliveryEmail DeliveryType = "email"
|
DeliveryEmail DeliveryType = "email" // Send via yao/messenger
|
||||||
DeliveryFile DeliveryType = "file"
|
DeliveryWebhook DeliveryType = "webhook" // POST to external URL
|
||||||
DeliveryWebhook DeliveryType = "webhook"
|
DeliveryProcess DeliveryType = "process" // Call Yao Process
|
||||||
DeliveryNotify DeliveryType = "notify"
|
DeliveryNotify DeliveryType = "notify" // In-app notification (future, auto by subscriptions)
|
||||||
)
|
)
|
||||||
|
|
||||||
// DedupResult - deduplication result
|
// DedupResult - deduplication result
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,8 @@ func TestPriorityEnum(t *testing.T) {
|
||||||
|
|
||||||
func TestDeliveryTypeEnum(t *testing.T) {
|
func TestDeliveryTypeEnum(t *testing.T) {
|
||||||
assert.Equal(t, types.DeliveryType("email"), types.DeliveryEmail)
|
assert.Equal(t, types.DeliveryType("email"), types.DeliveryEmail)
|
||||||
assert.Equal(t, types.DeliveryType("file"), types.DeliveryFile)
|
|
||||||
assert.Equal(t, types.DeliveryType("webhook"), types.DeliveryWebhook)
|
assert.Equal(t, types.DeliveryType("webhook"), types.DeliveryWebhook)
|
||||||
|
assert.Equal(t, types.DeliveryType("process"), types.DeliveryProcess)
|
||||||
assert.Equal(t, types.DeliveryType("notify"), types.DeliveryNotify)
|
assert.Equal(t, types.DeliveryType("notify"), types.DeliveryNotify)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ type Robot struct {
|
||||||
SystemPrompt string `json:"system_prompt"`
|
SystemPrompt string `json:"system_prompt"`
|
||||||
Status RobotStatus `json:"robot_status"`
|
Status RobotStatus `json:"robot_status"`
|
||||||
AutonomousMode bool `json:"autonomous_mode"`
|
AutonomousMode bool `json:"autonomous_mode"`
|
||||||
|
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
|
||||||
|
|
||||||
// Parsed config (from robot_config JSON field)
|
// Parsed config (from robot_config JSON field)
|
||||||
Config *Config `json:"-"`
|
Config *Config `json:"-"`
|
||||||
|
|
@ -262,15 +263,100 @@ type ValidationResult struct {
|
||||||
ReplyContent string `json:"reply_content,omitempty"` // content for next turn (if NeedReply)
|
ReplyContent string `json:"reply_content,omitempty"` // content for next turn (if NeedReply)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryResult - P4 delivery output
|
// DeliveryResult - P4 delivery output (new architecture)
|
||||||
type DeliveryResult struct {
|
type DeliveryResult struct {
|
||||||
Type DeliveryType `json:"type"`
|
RequestID string `json:"request_id"` // Delivery request ID
|
||||||
Success bool `json:"success"`
|
Content *DeliveryContent `json:"content"` // Agent-generated content
|
||||||
Recipients []string `json:"recipients,omitempty"` // who received
|
Results []ChannelResult `json:"results,omitempty"` // Results per channel
|
||||||
Content string `json:"content,omitempty"` // formatted content that was delivered
|
Success bool `json:"success"` // Overall success
|
||||||
Details interface{} `json:"details,omitempty"` // channel-specific response
|
Error string `json:"error,omitempty"` // Error if failed
|
||||||
Error string `json:"error,omitempty"`
|
SentAt *time.Time `json:"sent_at,omitempty"` // When delivery completed
|
||||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
}
|
||||||
|
|
||||||
|
// DeliveryContent - Content generated by Delivery Agent (only content, no channels)
|
||||||
|
type DeliveryContent struct {
|
||||||
|
Summary string `json:"summary"` // Brief 1-2 sentence summary
|
||||||
|
Body string `json:"body"` // Full markdown report
|
||||||
|
Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts from P3
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryAttachment - Task output attachment with metadata
|
||||||
|
type DeliveryAttachment struct {
|
||||||
|
Title string `json:"title"` // Human-readable title
|
||||||
|
Description string `json:"description,omitempty"` // What this artifact is
|
||||||
|
TaskID string `json:"task_id,omitempty"` // Which task produced this
|
||||||
|
File string `json:"file"` // Wrapper: __<uploader>://<fileID>
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryRequest - pushed to Delivery Center (no channels - center decides based on preferences)
|
||||||
|
type DeliveryRequest struct {
|
||||||
|
Content *DeliveryContent `json:"content"` // Agent-generated content
|
||||||
|
Context *DeliveryContext `json:"context"` // Tracking info
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryContext - tracking and audit info
|
||||||
|
type DeliveryContext struct {
|
||||||
|
MemberID string `json:"member_id"` // Robot member ID (globally unique)
|
||||||
|
ExecutionID string `json:"execution_id"` // Execution ID
|
||||||
|
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
||||||
|
TeamID string `json:"team_id"` // Team ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryPreferences - Robot/User delivery preferences (from Config)
|
||||||
|
type DeliveryPreferences struct {
|
||||||
|
Email *EmailPreference `json:"email,omitempty"` // Email delivery settings
|
||||||
|
Webhook *WebhookPreference `json:"webhook,omitempty"` // Webhook delivery settings
|
||||||
|
Process *ProcessPreference `json:"process,omitempty"` // Process delivery settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailPreference - Email delivery configuration
|
||||||
|
type EmailPreference struct {
|
||||||
|
Enabled bool `json:"enabled"` // Whether email delivery is enabled
|
||||||
|
Targets []EmailTarget `json:"targets,omitempty"` // Multiple email targets
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailTarget - Single email target
|
||||||
|
type EmailTarget struct {
|
||||||
|
To []string `json:"to"` // Recipient addresses
|
||||||
|
Template string `json:"template,omitempty"` // Email template ID
|
||||||
|
Subject string `json:"subject,omitempty"` // Subject template
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookPreference - Webhook delivery configuration
|
||||||
|
type WebhookPreference struct {
|
||||||
|
Enabled bool `json:"enabled"` // Whether webhook delivery is enabled
|
||||||
|
Targets []WebhookTarget `json:"targets,omitempty"` // Multiple webhook targets
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookTarget - Single webhook target
|
||||||
|
type WebhookTarget struct {
|
||||||
|
URL string `json:"url"` // Webhook URL
|
||||||
|
Method string `json:"method,omitempty"` // HTTP method (default: POST)
|
||||||
|
Headers map[string]string `json:"headers,omitempty"` // Custom headers
|
||||||
|
Secret string `json:"secret,omitempty"` // Signing secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessPreference - Process delivery configuration
|
||||||
|
type ProcessPreference struct {
|
||||||
|
Enabled bool `json:"enabled"` // Whether process delivery is enabled
|
||||||
|
Targets []ProcessTarget `json:"targets,omitempty"` // Multiple process targets
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessTarget - Single process target
|
||||||
|
type ProcessTarget struct {
|
||||||
|
Process string `json:"process"` // Yao Process name
|
||||||
|
Args []any `json:"args,omitempty"` // Process arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelResult - Result of delivery to a single channel target
|
||||||
|
type ChannelResult struct {
|
||||||
|
Type DeliveryType `json:"type"` // email | webhook | process
|
||||||
|
Target string `json:"target"` // Target identifier (email, URL, process name)
|
||||||
|
Success bool `json:"success"` // Whether delivery succeeded
|
||||||
|
Recipients []string `json:"recipients,omitempty"` // Who received (for email)
|
||||||
|
Details interface{} `json:"details,omitempty"` // Channel-specific response
|
||||||
|
Error string `json:"error,omitempty"` // Error message if failed
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty"` // When this target was delivered
|
||||||
}
|
}
|
||||||
|
|
||||||
// LearningEntry - knowledge to save
|
// LearningEntry - knowledge to save
|
||||||
|
|
@ -297,6 +383,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
|
||||||
DisplayName: getString(m, "display_name"),
|
DisplayName: getString(m, "display_name"),
|
||||||
SystemPrompt: getString(m, "system_prompt"),
|
SystemPrompt: getString(m, "system_prompt"),
|
||||||
AutonomousMode: getBool(m, "autonomous_mode"),
|
AutonomousMode: getBool(m, "autonomous_mode"),
|
||||||
|
RobotEmail: getString(m, "robot_email"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse robot_status
|
// Parse robot_status
|
||||||
|
|
|
||||||
|
|
@ -536,25 +536,231 @@ func TestValidationResultMultiTurnFields(t *testing.T) {
|
||||||
func TestDeliveryResultStructure(t *testing.T) {
|
func TestDeliveryResultStructure(t *testing.T) {
|
||||||
sentAt := time.Now()
|
sentAt := time.Now()
|
||||||
delivery := &types.DeliveryResult{
|
delivery := &types.DeliveryResult{
|
||||||
|
RequestID: "req-12345",
|
||||||
|
Content: &types.DeliveryContent{
|
||||||
|
Summary: "Weekly sales report completed",
|
||||||
|
Body: "# Weekly Report\n\nSales increased by 20%...",
|
||||||
|
Attachments: []types.DeliveryAttachment{
|
||||||
|
{
|
||||||
|
Title: "Sales Report",
|
||||||
|
Description: "Detailed sales analysis",
|
||||||
|
TaskID: "task-1",
|
||||||
|
File: "__s3://report-12345.pdf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Results: []types.ChannelResult{
|
||||||
|
{
|
||||||
Type: types.DeliveryEmail,
|
Type: types.DeliveryEmail,
|
||||||
|
Target: "user@example.com",
|
||||||
Success: true,
|
Success: true,
|
||||||
Recipients: []string{"user@example.com", "manager@example.com"},
|
|
||||||
Content: "# Weekly Report\n\nSales increased by 20%...",
|
|
||||||
Details: map[string]interface{}{
|
Details: map[string]interface{}{
|
||||||
"message_id": "msg-12345",
|
"message_id": "msg-12345",
|
||||||
"subject": "Daily Report",
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: types.DeliveryWebhook,
|
||||||
|
Target: "https://webhook.example.com/notify",
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
SentAt: &sentAt,
|
SentAt: &sentAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, types.DeliveryEmail, delivery.Type)
|
assert.Equal(t, "req-12345", delivery.RequestID)
|
||||||
assert.True(t, delivery.Success)
|
assert.True(t, delivery.Success)
|
||||||
assert.Len(t, delivery.Recipients, 2)
|
assert.NotNil(t, delivery.Content)
|
||||||
assert.Contains(t, delivery.Content, "Weekly Report")
|
assert.Equal(t, "Weekly sales report completed", delivery.Content.Summary)
|
||||||
assert.NotNil(t, delivery.Details)
|
assert.Contains(t, delivery.Content.Body, "Weekly Report")
|
||||||
|
assert.Len(t, delivery.Content.Attachments, 1)
|
||||||
|
assert.Equal(t, "__s3://report-12345.pdf", delivery.Content.Attachments[0].File)
|
||||||
|
assert.Len(t, delivery.Results, 2)
|
||||||
|
assert.Equal(t, types.DeliveryEmail, delivery.Results[0].Type)
|
||||||
assert.NotNil(t, delivery.SentAt)
|
assert.NotNil(t, delivery.SentAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeliveryContentStructure(t *testing.T) {
|
||||||
|
content := &types.DeliveryContent{
|
||||||
|
Summary: "Task execution completed successfully",
|
||||||
|
Body: "# Execution Report\n\n## Summary\n- 3 tasks completed\n- 1 task failed",
|
||||||
|
Attachments: []types.DeliveryAttachment{
|
||||||
|
{
|
||||||
|
Title: "Analysis Results",
|
||||||
|
Description: "JSON data from analysis task",
|
||||||
|
TaskID: "task-analysis",
|
||||||
|
File: "__local://files/analysis-result.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Generated Chart",
|
||||||
|
TaskID: "task-chart",
|
||||||
|
File: "__s3://charts/sales-chart.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEmpty(t, content.Summary)
|
||||||
|
assert.Contains(t, content.Body, "Execution Report")
|
||||||
|
assert.Len(t, content.Attachments, 2)
|
||||||
|
assert.Equal(t, "Analysis Results", content.Attachments[0].Title)
|
||||||
|
assert.Equal(t, "task-analysis", content.Attachments[0].TaskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliveryAttachmentStructure(t *testing.T) {
|
||||||
|
attachment := &types.DeliveryAttachment{
|
||||||
|
Title: "Sales Report PDF",
|
||||||
|
Description: "Monthly sales analysis report",
|
||||||
|
TaskID: "task-report",
|
||||||
|
File: "__s3://reports/sales-2024-01.pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "Sales Report PDF", attachment.Title)
|
||||||
|
assert.Equal(t, "Monthly sales analysis report", attachment.Description)
|
||||||
|
assert.Equal(t, "task-report", attachment.TaskID)
|
||||||
|
assert.Contains(t, attachment.File, "__s3://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliveryRequestStructure(t *testing.T) {
|
||||||
|
request := &types.DeliveryRequest{
|
||||||
|
Content: &types.DeliveryContent{
|
||||||
|
Summary: "Report ready",
|
||||||
|
Body: "# Report\n\nDetails...",
|
||||||
|
},
|
||||||
|
Context: &types.DeliveryContext{
|
||||||
|
MemberID: "member-123",
|
||||||
|
ExecutionID: "exec-456",
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
TeamID: "team-789",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, request.Content)
|
||||||
|
assert.NotNil(t, request.Context)
|
||||||
|
assert.Equal(t, "member-123", request.Context.MemberID)
|
||||||
|
assert.Equal(t, "exec-456", request.Context.ExecutionID)
|
||||||
|
assert.Equal(t, types.TriggerClock, request.Context.TriggerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliveryPreferencesStructure(t *testing.T) {
|
||||||
|
prefs := &types.DeliveryPreferences{
|
||||||
|
Email: &types.EmailPreference{
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []types.EmailTarget{
|
||||||
|
{
|
||||||
|
To: []string{"team@example.com"},
|
||||||
|
Template: "weekly-report",
|
||||||
|
Subject: "Weekly Report - {{.Date}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
To: []string{"backup@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Webhook: &types.WebhookPreference{
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []types.WebhookTarget{
|
||||||
|
{
|
||||||
|
URL: "https://api.example.com/webhook",
|
||||||
|
Method: "POST",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"X-API-Key": "secret-key",
|
||||||
|
},
|
||||||
|
Secret: "signing-secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Process: &types.ProcessPreference{
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []types.ProcessTarget{
|
||||||
|
{
|
||||||
|
Process: "scripts.notify.slack",
|
||||||
|
Args: []any{"#general", "Report ready"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email
|
||||||
|
assert.True(t, prefs.Email.Enabled)
|
||||||
|
assert.Len(t, prefs.Email.Targets, 2)
|
||||||
|
assert.Equal(t, "weekly-report", prefs.Email.Targets[0].Template)
|
||||||
|
assert.Len(t, prefs.Email.Targets[0].To, 1)
|
||||||
|
|
||||||
|
// Webhook
|
||||||
|
assert.True(t, prefs.Webhook.Enabled)
|
||||||
|
assert.Len(t, prefs.Webhook.Targets, 1)
|
||||||
|
assert.Equal(t, "https://api.example.com/webhook", prefs.Webhook.Targets[0].URL)
|
||||||
|
assert.Equal(t, "POST", prefs.Webhook.Targets[0].Method)
|
||||||
|
|
||||||
|
// Process
|
||||||
|
assert.True(t, prefs.Process.Enabled)
|
||||||
|
assert.Len(t, prefs.Process.Targets, 1)
|
||||||
|
assert.Equal(t, "scripts.notify.slack", prefs.Process.Targets[0].Process)
|
||||||
|
assert.Len(t, prefs.Process.Targets[0].Args, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelResultStructure(t *testing.T) {
|
||||||
|
t.Run("email result with recipients", func(t *testing.T) {
|
||||||
|
sentAt := time.Now()
|
||||||
|
result := &types.ChannelResult{
|
||||||
|
Type: types.DeliveryEmail,
|
||||||
|
Target: "user@example.com",
|
||||||
|
Success: true,
|
||||||
|
Recipients: []string{"user@example.com", "manager@example.com"},
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"message_id": "msg-123",
|
||||||
|
},
|
||||||
|
SentAt: &sentAt,
|
||||||
|
}
|
||||||
|
assert.Equal(t, types.DeliveryEmail, result.Type)
|
||||||
|
assert.Equal(t, "user@example.com", result.Target)
|
||||||
|
assert.True(t, result.Success)
|
||||||
|
assert.Len(t, result.Recipients, 2)
|
||||||
|
assert.NotNil(t, result.SentAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("webhook result", func(t *testing.T) {
|
||||||
|
sentAt := time.Now()
|
||||||
|
result := &types.ChannelResult{
|
||||||
|
Type: types.DeliveryWebhook,
|
||||||
|
Target: "https://api.example.com/webhook",
|
||||||
|
Success: true,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"status_code": 200,
|
||||||
|
"response": "OK",
|
||||||
|
},
|
||||||
|
SentAt: &sentAt,
|
||||||
|
}
|
||||||
|
assert.Equal(t, types.DeliveryWebhook, result.Type)
|
||||||
|
assert.True(t, result.Success)
|
||||||
|
assert.NotNil(t, result.SentAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("process result", func(t *testing.T) {
|
||||||
|
result := &types.ChannelResult{
|
||||||
|
Type: types.DeliveryProcess,
|
||||||
|
Target: "scripts.notify.slack",
|
||||||
|
Success: true,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"output": "Message sent",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Equal(t, types.DeliveryProcess, result.Type)
|
||||||
|
assert.Equal(t, "scripts.notify.slack", result.Target)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed result", func(t *testing.T) {
|
||||||
|
result := &types.ChannelResult{
|
||||||
|
Type: types.DeliveryWebhook,
|
||||||
|
Target: "https://api.example.com/webhook",
|
||||||
|
Success: false,
|
||||||
|
Error: "Connection refused",
|
||||||
|
}
|
||||||
|
assert.False(t, result.Success)
|
||||||
|
assert.Equal(t, "Connection refused", result.Error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliveryTargetStructure(t *testing.T) {
|
func TestDeliveryTargetStructure(t *testing.T) {
|
||||||
delivery := &types.DeliveryTarget{
|
delivery := &types.DeliveryTarget{
|
||||||
Type: types.DeliveryEmail,
|
Type: types.DeliveryEmail,
|
||||||
|
|
|
||||||
353
data/bindata.go
353
data/bindata.go
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ package mailer
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
|
@ -370,6 +371,20 @@ func (p *Provider) buildMessage(message *types.Message) (string, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we have attachments
|
||||||
|
hasAttachments := len(message.Attachments) > 0
|
||||||
|
|
||||||
|
if hasAttachments {
|
||||||
|
// Use multipart/mixed for attachments
|
||||||
|
return p.buildMessageWithAttachments(&content, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No attachments - use simple format
|
||||||
|
return p.buildMessageSimple(&content, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessageSimple builds email without attachments
|
||||||
|
func (p *Provider) buildMessageSimple(content *strings.Builder, message *types.Message) (string, error) {
|
||||||
// MIME headers for HTML content
|
// MIME headers for HTML content
|
||||||
if message.HTML != "" {
|
if message.HTML != "" {
|
||||||
content.WriteString("MIME-Version: 1.0\r\n")
|
content.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
|
@ -402,6 +417,107 @@ func (p *Provider) buildMessage(message *types.Message) (string, error) {
|
||||||
return content.String(), nil
|
return content.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildMessageWithAttachments builds email with attachments using multipart/mixed
|
||||||
|
func (p *Provider) buildMessageWithAttachments(content *strings.Builder, message *types.Message) (string, error) {
|
||||||
|
// Use unique boundaries
|
||||||
|
mixedBoundary := fmt.Sprintf("mixed_%d", time.Now().UnixNano())
|
||||||
|
altBoundary := fmt.Sprintf("alt_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
content.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
content.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", mixedBoundary))
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
|
||||||
|
// Start mixed boundary
|
||||||
|
content.WriteString(fmt.Sprintf("--%s\r\n", mixedBoundary))
|
||||||
|
|
||||||
|
// Add body content
|
||||||
|
if message.HTML != "" && message.Body != "" {
|
||||||
|
// Both text and HTML - use multipart/alternative
|
||||||
|
content.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", altBoundary))
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
|
||||||
|
// Plain text part
|
||||||
|
content.WriteString(fmt.Sprintf("--%s\r\n", altBoundary))
|
||||||
|
content.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||||
|
content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
content.WriteString(message.Body)
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
|
||||||
|
// HTML part
|
||||||
|
content.WriteString(fmt.Sprintf("--%s\r\n", altBoundary))
|
||||||
|
content.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||||
|
content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
content.WriteString(message.HTML)
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
|
||||||
|
// End alternative boundary
|
||||||
|
content.WriteString(fmt.Sprintf("--%s--\r\n", altBoundary))
|
||||||
|
} else if message.HTML != "" {
|
||||||
|
// HTML only
|
||||||
|
content.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||||
|
content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
content.WriteString(message.HTML)
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
} else {
|
||||||
|
// Plain text only
|
||||||
|
content.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||||
|
content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
content.WriteString(message.Body)
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add attachments
|
||||||
|
for _, attachment := range message.Attachments {
|
||||||
|
content.WriteString(fmt.Sprintf("--%s\r\n", mixedBoundary))
|
||||||
|
|
||||||
|
// Determine content type
|
||||||
|
contentType := attachment.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine disposition
|
||||||
|
disposition := "attachment"
|
||||||
|
if attachment.Inline {
|
||||||
|
disposition = "inline"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write attachment headers
|
||||||
|
content.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", contentType, attachment.Filename))
|
||||||
|
content.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||||
|
content.WriteString(fmt.Sprintf("Content-Disposition: %s; filename=\"%s\"\r\n", disposition, attachment.Filename))
|
||||||
|
|
||||||
|
// Add Content-ID for inline attachments
|
||||||
|
if attachment.Inline && attachment.CID != "" {
|
||||||
|
content.WriteString(fmt.Sprintf("Content-ID: <%s>\r\n", attachment.CID))
|
||||||
|
}
|
||||||
|
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
|
||||||
|
// Encode attachment content as base64
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(attachment.Content)
|
||||||
|
|
||||||
|
// Split into 76-character lines (RFC 2045)
|
||||||
|
for i := 0; i < len(encoded); i += 76 {
|
||||||
|
end := i + 76
|
||||||
|
if end > len(encoded) {
|
||||||
|
end = len(encoded)
|
||||||
|
}
|
||||||
|
content.WriteString(encoded[i:end])
|
||||||
|
content.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End mixed boundary
|
||||||
|
content.WriteString(fmt.Sprintf("--%s--\r\n", mixedBoundary))
|
||||||
|
|
||||||
|
return content.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// extractEmailAddress extracts the email address from a string that may contain display name
|
// extractEmailAddress extracts the email address from a string that may contain display name
|
||||||
// e.g., "John Doe <john@example.com>" -> "john@example.com"
|
// e.g., "John Doe <john@example.com>" -> "john@example.com"
|
||||||
func extractEmailAddress(address string) string {
|
func extractEmailAddress(address string) string {
|
||||||
|
|
|
||||||
|
|
@ -768,3 +768,156 @@ func TestProvider_TriggerWebhook(t *testing.T) {
|
||||||
assert.Nil(t, msg)
|
assert.Nil(t, msg)
|
||||||
assert.Contains(t, err.Error(), "TriggerWebhook not supported for SMTP/mailer provider")
|
assert.Contains(t, err.Error(), "TriggerWebhook not supported for SMTP/mailer provider")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attachment Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestBuildMessage_WithAttachments(t *testing.T) {
|
||||||
|
config := types.ProviderConfig{
|
||||||
|
Name: "test",
|
||||||
|
Connector: "mailer",
|
||||||
|
Options: map[string]interface{}{
|
||||||
|
"smtp": map[string]interface{}{
|
||||||
|
"host": "smtp.example.com",
|
||||||
|
"port": 587,
|
||||||
|
"username": "test@example.com",
|
||||||
|
"password": "testpass",
|
||||||
|
"from": "sender@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := NewMailerProvider(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("single_attachment", func(t *testing.T) {
|
||||||
|
message := &types.Message{
|
||||||
|
Type: types.MessageTypeEmail,
|
||||||
|
To: []string{"test@example.com"},
|
||||||
|
Subject: "Test with Attachment",
|
||||||
|
Body: "This is a test email with attachment",
|
||||||
|
Attachments: []types.Attachment{
|
||||||
|
{
|
||||||
|
Filename: "test.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Content: []byte("Hello, this is test content!"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := provider.buildMessage(message)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify multipart/mixed boundary
|
||||||
|
assert.Contains(t, content, "multipart/mixed")
|
||||||
|
assert.Contains(t, content, "Content-Disposition: attachment")
|
||||||
|
assert.Contains(t, content, `filename="test.txt"`)
|
||||||
|
assert.Contains(t, content, "Content-Transfer-Encoding: base64")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple_attachments", func(t *testing.T) {
|
||||||
|
message := &types.Message{
|
||||||
|
Type: types.MessageTypeEmail,
|
||||||
|
To: []string{"test@example.com"},
|
||||||
|
Subject: "Test with Multiple Attachments",
|
||||||
|
Body: "This is a test email with multiple attachments",
|
||||||
|
HTML: "<p>This is a test email with multiple attachments</p>",
|
||||||
|
Attachments: []types.Attachment{
|
||||||
|
{
|
||||||
|
Filename: "doc1.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Content: []byte("Document 1 content"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Filename: "doc2.pdf",
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
Content: []byte("%PDF-1.4 fake pdf"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := provider.buildMessage(message)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify both attachments are present
|
||||||
|
assert.Contains(t, content, `filename="doc1.txt"`)
|
||||||
|
assert.Contains(t, content, `filename="doc2.pdf"`)
|
||||||
|
assert.Contains(t, content, "text/plain")
|
||||||
|
assert.Contains(t, content, "application/pdf")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inline_attachment", func(t *testing.T) {
|
||||||
|
message := &types.Message{
|
||||||
|
Type: types.MessageTypeEmail,
|
||||||
|
To: []string{"test@example.com"},
|
||||||
|
Subject: "Test with Inline Image",
|
||||||
|
HTML: `<p>Image: <img src="cid:logo123"></p>`,
|
||||||
|
Attachments: []types.Attachment{
|
||||||
|
{
|
||||||
|
Filename: "logo.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
Content: []byte{0x89, 0x50, 0x4E, 0x47}, // PNG magic bytes
|
||||||
|
Inline: true,
|
||||||
|
CID: "logo123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := provider.buildMessage(message)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify inline disposition and Content-ID
|
||||||
|
assert.Contains(t, content, "Content-Disposition: inline")
|
||||||
|
assert.Contains(t, content, "Content-ID: <logo123>")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no_attachments", func(t *testing.T) {
|
||||||
|
message := &types.Message{
|
||||||
|
Type: types.MessageTypeEmail,
|
||||||
|
To: []string{"test@example.com"},
|
||||||
|
Subject: "Test without Attachment",
|
||||||
|
Body: "This is a plain text email",
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := provider.buildMessage(message)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should not contain multipart/mixed
|
||||||
|
assert.NotContains(t, content, "multipart/mixed")
|
||||||
|
assert.Contains(t, content, "text/plain")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_EmailWithAttachments_RealAPI(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real API test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
config := loadPrimaryTestConfig(t)
|
||||||
|
provider, err := NewMailerProvider(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
emailMessage := &types.Message{
|
||||||
|
Type: types.MessageTypeEmail,
|
||||||
|
To: []string{TestEmailAgent},
|
||||||
|
Subject: "SMTP Test Email with Attachment - " + time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
Body: "This is a test email with attachment sent via SMTP",
|
||||||
|
HTML: "<h1>SMTP Test</h1><p>This email has an attachment.</p>",
|
||||||
|
Attachments: []types.Attachment{
|
||||||
|
{
|
||||||
|
Filename: "test-attachment.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Content: []byte("This is a test attachment content.\nLine 2 of the attachment.\nLine 3."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = provider.Send(ctx, emailMessage)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Real SMTP call with attachment failed (may be expected in CI): %v", err)
|
||||||
|
} else {
|
||||||
|
t.Log("Real SMTP call with attachment succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package mailgun
|
package mailgun
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -218,6 +221,17 @@ func (p *Provider) Close() error {
|
||||||
func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error {
|
func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error {
|
||||||
apiURL := fmt.Sprintf("%s/%s/messages", p.baseURL, p.domain)
|
apiURL := fmt.Sprintf("%s/%s/messages", p.baseURL, p.domain)
|
||||||
|
|
||||||
|
// Check if we have attachments - use multipart/form-data if so
|
||||||
|
if len(message.Attachments) > 0 {
|
||||||
|
return p.sendEmailWithAttachments(ctx, apiURL, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No attachments - use simple URL-encoded form
|
||||||
|
return p.sendEmailSimple(ctx, apiURL, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendEmailSimple sends email without attachments using URL-encoded form
|
||||||
|
func (p *Provider) sendEmailSimple(ctx context.Context, apiURL string, message *types.Message) error {
|
||||||
// Prepare form data
|
// Prepare form data
|
||||||
data := url.Values{}
|
data := url.Values{}
|
||||||
|
|
||||||
|
|
@ -295,3 +309,134 @@ func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendEmailWithAttachments sends email with attachments using multipart/form-data
|
||||||
|
func (p *Provider) sendEmailWithAttachments(ctx context.Context, apiURL string, message *types.Message) error {
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
|
||||||
|
// From address
|
||||||
|
from := message.From
|
||||||
|
if from == "" {
|
||||||
|
from = p.from
|
||||||
|
}
|
||||||
|
if err := writer.WriteField("from", from); err != nil {
|
||||||
|
return fmt.Errorf("failed to write from field: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// To addresses
|
||||||
|
for _, to := range message.To {
|
||||||
|
if err := writer.WriteField("to", to); err != nil {
|
||||||
|
return fmt.Errorf("failed to write to field: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject
|
||||||
|
if err := writer.WriteField("subject", message.Subject); err != nil {
|
||||||
|
return fmt.Errorf("failed to write subject field: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text body
|
||||||
|
if message.Body != "" {
|
||||||
|
if err := writer.WriteField("text", message.Body); err != nil {
|
||||||
|
return fmt.Errorf("failed to write text field: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML body
|
||||||
|
if message.HTML != "" {
|
||||||
|
if err := writer.WriteField("html", message.HTML); err != nil {
|
||||||
|
return fmt.Errorf("failed to write html field: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom headers
|
||||||
|
if message.Headers != nil {
|
||||||
|
for key, value := range message.Headers {
|
||||||
|
if err := writer.WriteField("h:"+key, value); err != nil {
|
||||||
|
return fmt.Errorf("failed to write header field: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom variables (metadata)
|
||||||
|
if message.Metadata != nil {
|
||||||
|
for key, value := range message.Metadata {
|
||||||
|
if str, ok := value.(string); ok {
|
||||||
|
if err := writer.WriteField("v:"+key, str); err != nil {
|
||||||
|
return fmt.Errorf("failed to write metadata field: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority
|
||||||
|
if message.Priority > 0 {
|
||||||
|
if err := writer.WriteField("o:priority", fmt.Sprintf("%d", message.Priority)); err != nil {
|
||||||
|
return fmt.Errorf("failed to write priority field: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduled sending
|
||||||
|
if message.ScheduledAt != nil {
|
||||||
|
if err := writer.WriteField("o:deliverytime", message.ScheduledAt.Format(time.RFC1123Z)); err != nil {
|
||||||
|
return fmt.Errorf("failed to write deliverytime field: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add attachments
|
||||||
|
for _, attachment := range message.Attachments {
|
||||||
|
fieldName := "attachment"
|
||||||
|
if attachment.Inline {
|
||||||
|
fieldName = "inline"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create form file with proper headers
|
||||||
|
h := make(textproto.MIMEHeader)
|
||||||
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, attachment.Filename))
|
||||||
|
if attachment.ContentType != "" {
|
||||||
|
h.Set("Content-Type", attachment.ContentType)
|
||||||
|
} else {
|
||||||
|
h.Set("Content-Type", "application/octet-stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
part, err := writer.CreatePart(h)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create attachment part: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := part.Write(attachment.Content); err != nil {
|
||||||
|
return fmt.Errorf("failed to write attachment content: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close multipart writer
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close multipart writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request with context
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, &body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set authentication and content type
|
||||||
|
req.SetBasicAuth("api", p.apiKey)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("Mailgun API error: %s - %s", resp.Status, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) {
|
||||||
// Extract common Mailgun webhook fields
|
// Extract common Mailgun webhook fields
|
||||||
event := ginCtx.Request.FormValue("event")
|
event := ginCtx.Request.FormValue("event")
|
||||||
recipient := ginCtx.Request.FormValue("recipient")
|
recipient := ginCtx.Request.FormValue("recipient")
|
||||||
messageId := ginCtx.Request.FormValue("message-id")
|
messageID := ginCtx.Request.FormValue("message-id")
|
||||||
timestamp := ginCtx.Request.FormValue("timestamp")
|
timestamp := ginCtx.Request.FormValue("timestamp")
|
||||||
token := ginCtx.Request.FormValue("token")
|
token := ginCtx.Request.FormValue("token")
|
||||||
signature := ginCtx.Request.FormValue("signature")
|
signature := ginCtx.Request.FormValue("signature")
|
||||||
|
|
@ -38,8 +38,8 @@ func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) {
|
||||||
if recipient != "" {
|
if recipient != "" {
|
||||||
message.To = []string{recipient}
|
message.To = []string{recipient}
|
||||||
}
|
}
|
||||||
if messageId != "" {
|
if messageID != "" {
|
||||||
message.Metadata["message_id"] = messageId
|
message.Metadata["message_id"] = messageID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store webhook-specific data
|
// Store webhook-specific data
|
||||||
|
|
|
||||||
|
|
@ -551,3 +551,130 @@ func BenchmarkValidate(b *testing.B) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attachment Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestSend_EmailWithAttachments_MockServer(t *testing.T) {
|
||||||
|
// Create a mock HTTP server that validates the multipart request
|
||||||
|
var receivedContentType string
|
||||||
|
var receivedBody []byte
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedContentType = r.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
// Read the body
|
||||||
|
body, _ := r.Body.Read(make([]byte, 1024*1024))
|
||||||
|
_ = body
|
||||||
|
receivedBody = make([]byte, r.ContentLength)
|
||||||
|
r.Body.Read(receivedBody)
|
||||||
|
|
||||||
|
// Return success
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id": "test-id", "message": "Queued"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
config := loadTestConfig(t)
|
||||||
|
provider, err := NewMailgunProvider(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Override base URL to use mock server
|
||||||
|
provider.baseURL = server.URL
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
emailMessage := &types.Message{
|
||||||
|
Type: types.MessageTypeEmail,
|
||||||
|
To: []string{"test@example.com"},
|
||||||
|
Subject: "Test Email with Attachment",
|
||||||
|
Body: "This is a test email with attachment",
|
||||||
|
HTML: "<h1>Test</h1><p>This is a test email with attachment</p>",
|
||||||
|
Attachments: []types.Attachment{
|
||||||
|
{
|
||||||
|
Filename: "test.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Content: []byte("Hello, this is a test attachment content!"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Filename: "test.pdf",
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
Content: []byte("%PDF-1.4 fake pdf content"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = provider.Send(ctx, emailMessage)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the request used multipart/form-data
|
||||||
|
assert.Contains(t, receivedContentType, "multipart/form-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_EmailWithInlineAttachment_MockServer(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id": "test-id", "message": "Queued"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
config := loadTestConfig(t)
|
||||||
|
provider, err := NewMailgunProvider(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
provider.baseURL = server.URL
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
emailMessage := &types.Message{
|
||||||
|
Type: types.MessageTypeEmail,
|
||||||
|
To: []string{"test@example.com"},
|
||||||
|
Subject: "Test Email with Inline Image",
|
||||||
|
Body: "This is a test email with inline image",
|
||||||
|
HTML: `<h1>Test</h1><p>Image: <img src="cid:logo123"></p>`,
|
||||||
|
Attachments: []types.Attachment{
|
||||||
|
{
|
||||||
|
Filename: "logo.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
Content: []byte{0x89, 0x50, 0x4E, 0x47}, // PNG magic bytes
|
||||||
|
Inline: true,
|
||||||
|
CID: "logo123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = provider.Send(ctx, emailMessage)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_EmailWithAttachments_RealAPI(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real API test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
config := loadTestConfig(t)
|
||||||
|
provider, err := NewMailgunProvider(config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
emailMessage := &types.Message{
|
||||||
|
Type: types.MessageTypeEmail,
|
||||||
|
To: []string{TestEmailAgent},
|
||||||
|
Subject: "Unit Test Email with Attachment - " + time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
Body: "This is a unit test email with attachment sent via real Mailgun API",
|
||||||
|
HTML: "<h1>Unit Test</h1><p>This email has an attachment.</p>",
|
||||||
|
Attachments: []types.Attachment{
|
||||||
|
{
|
||||||
|
Filename: "test-attachment.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Content: []byte("This is a test attachment content.\nLine 2 of the attachment."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = provider.Send(ctx, emailMessage)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Real API call with attachment failed (may be expected in CI): %v", err)
|
||||||
|
} else {
|
||||||
|
t.Log("Real Mailgun API call with attachment succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import (
|
||||||
var systemModels = map[string]string{
|
var systemModels = map[string]string{
|
||||||
"__yao.agent.assistant": "yao/models/agent/assistant.mod.yao",
|
"__yao.agent.assistant": "yao/models/agent/assistant.mod.yao",
|
||||||
"__yao.agent.chat": "yao/models/agent/chat.mod.yao",
|
"__yao.agent.chat": "yao/models/agent/chat.mod.yao",
|
||||||
|
"__yao.agent.execution": "yao/models/agent/execution.mod.yao",
|
||||||
"__yao.agent.message": "yao/models/agent/message.mod.yao",
|
"__yao.agent.message": "yao/models/agent/message.mod.yao",
|
||||||
"__yao.agent.resume": "yao/models/agent/resume.mod.yao",
|
"__yao.agent.resume": "yao/models/agent/resume.mod.yao",
|
||||||
"__yao.agent.search": "yao/models/agent/search.mod.yao",
|
"__yao.agent.search": "yao/models/agent/search.mod.yao",
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,7 @@ var testServer *http.Server = nil
|
||||||
var testSystemModels = map[string]string{
|
var testSystemModels = map[string]string{
|
||||||
"__yao.agent.assistant": "yao/models/agent/assistant.mod.yao",
|
"__yao.agent.assistant": "yao/models/agent/assistant.mod.yao",
|
||||||
"__yao.agent.chat": "yao/models/agent/chat.mod.yao",
|
"__yao.agent.chat": "yao/models/agent/chat.mod.yao",
|
||||||
|
"__yao.agent.execution": "yao/models/agent/execution.mod.yao",
|
||||||
"__yao.agent.message": "yao/models/agent/message.mod.yao",
|
"__yao.agent.message": "yao/models/agent/message.mod.yao",
|
||||||
"__yao.agent.resume": "yao/models/agent/resume.mod.yao",
|
"__yao.agent.resume": "yao/models/agent/resume.mod.yao",
|
||||||
"__yao.agent.search": "yao/models/agent/search.mod.yao",
|
"__yao.agent.search": "yao/models/agent/search.mod.yao",
|
||||||
|
|
|
||||||
198
yao/models/agent/execution.mod.yao
Normal file
198
yao/models/agent/execution.mod.yao
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
{
|
||||||
|
"name": "Execution",
|
||||||
|
"label": "Robot Execution",
|
||||||
|
"description": "Robot execution history for tracking P0-P5 phase outputs and status",
|
||||||
|
"tags": ["agent", "robot", "system"],
|
||||||
|
"builtin": true,
|
||||||
|
"readonly": false,
|
||||||
|
"sort": 9999,
|
||||||
|
"table": { "name": "agent_execution", "comment": "Robot execution history table" },
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "ID",
|
||||||
|
"label": "ID",
|
||||||
|
"comment": "Auto-increment primary key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "execution_id",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Execution ID",
|
||||||
|
"comment": "Unique execution identifier",
|
||||||
|
"length": 64,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "member_id",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Member ID",
|
||||||
|
"comment": "Robot member ID (user identity from __yao.member)",
|
||||||
|
"length": 64,
|
||||||
|
"nullable": false,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "team_id",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Team ID",
|
||||||
|
"comment": "Team ID the robot belongs to",
|
||||||
|
"length": 64,
|
||||||
|
"nullable": false,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "job_id",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Job ID",
|
||||||
|
"comment": "Linked job.Job ID for monitoring",
|
||||||
|
"length": 64,
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trigger_type",
|
||||||
|
"type": "enum",
|
||||||
|
"label": "Trigger Type",
|
||||||
|
"comment": "How this execution was triggered",
|
||||||
|
"option": ["clock", "human", "event"],
|
||||||
|
"nullable": false,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"type": "enum",
|
||||||
|
"label": "Status",
|
||||||
|
"comment": "Execution status",
|
||||||
|
"option": ["pending", "running", "completed", "failed", "cancelled"],
|
||||||
|
"default": "pending",
|
||||||
|
"nullable": false,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phase",
|
||||||
|
"type": "enum",
|
||||||
|
"label": "Phase",
|
||||||
|
"comment": "Current execution phase",
|
||||||
|
"option": ["inspiration", "goals", "tasks", "run", "delivery", "learning"],
|
||||||
|
"default": "inspiration",
|
||||||
|
"nullable": false,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "current",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Current State",
|
||||||
|
"comment": "Current executing state (task_index, progress)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Error",
|
||||||
|
"comment": "Error message if execution failed",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "input",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Input",
|
||||||
|
"comment": "Original trigger input (TriggerInput)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "inspiration",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Inspiration",
|
||||||
|
"comment": "P0 output (InspirationReport)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "goals",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Goals",
|
||||||
|
"comment": "P1 output (Goals)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tasks",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Tasks",
|
||||||
|
"comment": "P2 output ([]Task)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "results",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Results",
|
||||||
|
"comment": "P3 output ([]TaskResult)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delivery",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Delivery",
|
||||||
|
"comment": "P4 output (DeliveryResult)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "learning",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Learning",
|
||||||
|
"comment": "P5 output ([]LearningEntry)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "start_time",
|
||||||
|
"type": "timestamp",
|
||||||
|
"label": "Start Time",
|
||||||
|
"comment": "Execution start timestamp",
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "end_time",
|
||||||
|
"type": "timestamp",
|
||||||
|
"label": "End Time",
|
||||||
|
"comment": "Execution end timestamp",
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relations": {
|
||||||
|
"member": {
|
||||||
|
"type": "hasOne",
|
||||||
|
"model": "__yao.member",
|
||||||
|
"key": "member_id",
|
||||||
|
"foreign": "member_id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"name": "idx_agent_execution_member_status",
|
||||||
|
"columns": ["member_id", "status"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index for member execution queries with status filter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_agent_execution_team_status",
|
||||||
|
"columns": ["team_id", "status"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index for team execution queries with status filter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_agent_execution_trigger_start",
|
||||||
|
"columns": ["trigger_type", "start_time"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index for trigger type analysis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_agent_execution_member_start",
|
||||||
|
"columns": ["member_id", "start_time"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index for robot execution history by member"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"option": { "timestamps": true, "soft_deletes": false, "permission": true }
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue