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_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` |
|
||||
| `system_prompt` | text | Identity & role prompt |
|
||||
| `robot_email` | string | Robot's email address for sending emails (From address) |
|
||||
| `agents` | JSON | Accessible agents list |
|
||||
| `mcp_servers` | JSON | Accessible MCP servers |
|
||||
| `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 |
|
||||
| P2 | Task Plan | Goals + tools | Tasks | 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 |
|
||||
|
||||
### 4.2 P0: Inspiration (Clock only)
|
||||
|
|
@ -316,7 +317,7 @@ type Goals 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
|
||||
Format string // markdown | html | json | text
|
||||
Template string // template name
|
||||
|
|
@ -489,15 +490,197 @@ Universal assertion library supporting 8 types:
|
|||
|
||||
### 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
|
||||
delivery:
|
||||
type: email # email | file | webhook | notify
|
||||
opts:
|
||||
to: ["manager@company.com"]
|
||||
preferences:
|
||||
email:
|
||||
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
|
||||
|
||||
Save to KB:
|
||||
|
|
@ -524,7 +707,7 @@ type Config struct {
|
|||
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning for private KB
|
||||
Resources *Resources `json:"resources"`
|
||||
Delivery *Delivery `json:"delivery"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *Executor `json:"executor,omitempty"` // executor mode settings
|
||||
}
|
||||
|
|
@ -564,10 +747,10 @@ const (
|
|||
type DeliveryType string
|
||||
|
||||
const (
|
||||
DeliveryEmail DeliveryType = "email"
|
||||
DeliveryFile DeliveryType = "file"
|
||||
DeliveryWebhook DeliveryType = "webhook"
|
||||
DeliveryNotify DeliveryType = "notify"
|
||||
DeliveryEmail DeliveryType = "email" // Email via yao/messenger
|
||||
DeliveryWebhook DeliveryType = "webhook" // POST to external URL
|
||||
DeliveryProcess DeliveryType = "process" // Yao Process call
|
||||
DeliveryNotify DeliveryType = "notify" // In-app notification (future)
|
||||
)
|
||||
|
||||
// ExecStatus - execution status enum
|
||||
|
|
@ -578,6 +761,7 @@ const (
|
|||
ExecRunning ExecStatus = "running"
|
||||
ExecCompleted ExecStatus = "completed"
|
||||
ExecFailed ExecStatus = "failed"
|
||||
ExecCancelled ExecStatus = "cancelled"
|
||||
)
|
||||
|
||||
// RobotStatus - matches __yao.member.robot_status enum
|
||||
|
|
@ -660,10 +844,46 @@ type MCP struct {
|
|||
Tools []string `json:"tools,omitempty"` // empty = all
|
||||
}
|
||||
|
||||
// Delivery
|
||||
type Delivery struct {
|
||||
Type DeliveryType `json:"type"`
|
||||
Opts map[string]interface{} `json:"opts"`
|
||||
// DeliveryPreferences - Robot 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
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -854,10 +854,10 @@ const (
|
|||
type DeliveryType string
|
||||
|
||||
const (
|
||||
DeliveryEmail DeliveryType = "email"
|
||||
DeliveryFile DeliveryType = "file"
|
||||
DeliveryWebhook DeliveryType = "webhook"
|
||||
DeliveryNotify DeliveryType = "notify"
|
||||
DeliveryEmail DeliveryType = "email" // Email via yao/messenger
|
||||
DeliveryWebhook DeliveryType = "webhook" // POST to external URL
|
||||
DeliveryProcess DeliveryType = "process" // Yao Process call
|
||||
DeliveryNotify DeliveryType = "notify" // In-app notification (future)
|
||||
)
|
||||
|
||||
// DedupResult - deduplication result
|
||||
|
|
@ -954,7 +954,7 @@ type Config struct {
|
|||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *Delivery `json:"delivery,omitempty"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // see section 6.2
|
||||
Events []Event `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -1134,11 +1134,7 @@ type MCPConfig struct {
|
|||
Tools []string `json:"tools,omitempty"` // empty = all
|
||||
}
|
||||
|
||||
// Delivery - output delivery
|
||||
type Delivery struct {
|
||||
Type DeliveryType `json:"type"`
|
||||
Opts map[string]interface{} `json:"opts,omitempty"`
|
||||
}
|
||||
// Note: Delivery preferences moved to DeliveryPreferences (see section 6.2)
|
||||
|
||||
// Event - event trigger config
|
||||
type Event struct {
|
||||
|
|
@ -1173,6 +1169,7 @@ type Robot struct {
|
|||
SystemPrompt string `json:"system_prompt"`
|
||||
Status RobotStatus `json:"robot_status"`
|
||||
AutonomousMode bool `json:"autonomous_mode"`
|
||||
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
|
||||
|
||||
// Parsed config (from robot_config JSON field)
|
||||
Config *Config `json:"-"`
|
||||
|
|
@ -1241,7 +1238,7 @@ func (r *Robot) GetExecutions() []*Execution {
|
|||
// Relationship: 1 Execution = 1 job.Job
|
||||
type Execution struct {
|
||||
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"`
|
||||
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
|
||||
StartTime time.Time `json:"start_time"`
|
||||
|
|
@ -1309,9 +1306,11 @@ type Goals struct {
|
|||
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 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
|
||||
Format string `json:"format,omitempty"` // markdown | html | json | text
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 DeliveryType `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Recipients []string `json:"recipients,omitempty"` // who received
|
||||
Content string `json:"content,omitempty"` // formatted content delivered
|
||||
Details interface{} `json:"details,omitempty"` // channel-specific response
|
||||
Error string `json:"error,omitempty"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
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
|
||||
}
|
||||
|
||||
// LearningEntry - knowledge to save
|
||||
|
|
@ -1893,3 +1981,493 @@ Supported assertion types:
|
|||
- `type` - type checking (with optional path)
|
||||
- `script` - custom script 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] `InterventionAction` - human actions (task.add, goal.adjust, etc.)
|
||||
- [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] `EventSource` - event sources (webhook, database)
|
||||
- [x] `LearningType` - learning types (execution, feedback, insight)
|
||||
|
|
@ -555,7 +555,7 @@ yao-dev-app/assistants/
|
|||
|
||||
- [x] `robot/delivery/package.yao` - config
|
||||
- [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
|
||||
- Style: Clear, professional
|
||||
|
||||
|
|
@ -869,92 +869,285 @@ Created new `yao/assert` package for universal assertion/validation:
|
|||
|
||||
**Depends on:** Phase 9 (P3 Run)
|
||||
|
||||
### 10.1 Delivery Agent Setup
|
||||
### 10.1 Execution Persistence (Prerequisite) ✅
|
||||
|
||||
- [ ] `robot/delivery/package.yao` - Delivery Agent config
|
||||
- [ ] `robot/delivery/prompts.yml` - delivery prompts
|
||||
> **Background:** Each Robot execution (P0-P5) needs persistent storage for UI history queries.
|
||||
|
||||
### 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
|
||||
- [ ] `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.2 Messenger Attachment Support ✅
|
||||
|
||||
### 10.3 Tests
|
||||
> **Conclusion:** All email providers now support attachments.
|
||||
|
||||
- [ ] `executor/delivery_test.go` - P4 delivery
|
||||
- [ ] Test: delivery content generated correctly
|
||||
- [ ] Test: email delivery (mock or real)
|
||||
- [ ] Test: file delivery to configured path
|
||||
**Implementation Status:**
|
||||
|
||||
| Provider | Attachment Support | Implementation |
|
||||
|----------|-------------------|----------------|
|
||||
| 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)
|
||||
|
||||
### 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
|
||||
- [ ] `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
|
||||
### 11.1 API Implementation
|
||||
|
||||
- [ ] `api/api.go` - implement all Go API functions
|
||||
- [ ] `api/process.go` - implement all Process handlers
|
||||
- [ ] `api/jsapi.go` - implement JSAPI
|
||||
|
||||
### 12.2 End-to-End Tests
|
||||
### 11.2 End-to-End Tests
|
||||
|
||||
- [ ] Full clock trigger flow (P0 → P5)
|
||||
- [ ] Human intervention flow (P1 → P5)
|
||||
- [ ] Event trigger flow (P1 → P5)
|
||||
- [ ] Full clock trigger flow (P0 → P1 → P2 → P3 → P4)
|
||||
- [ ] Human intervention flow (P1 → P2 → P3 → P4)
|
||||
- [ ] Event trigger flow (P1 → P2 → P3 → P4)
|
||||
- [ ] Concurrent execution test
|
||||
- [ ] Pause/Resume/Stop test
|
||||
|
||||
### 12.3 Integration with OpenAPI
|
||||
### 11.3 Integration with OpenAPI
|
||||
|
||||
- [ ] HTTP endpoints for human intervention
|
||||
- [ ] 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.
|
||||
|
||||
|
|
@ -966,13 +1159,13 @@ Created new `yao/assert` package for universal assertion/validation:
|
|||
- [ ] Integrate into Manager.Tick()
|
||||
- [ ] 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 Agent setup (`assistants/robot/dedup/`)
|
||||
- [ ] Test: semantic dedup with real LLM
|
||||
|
||||
### 13.3 Plan Queue
|
||||
### 12.4 Plan Queue
|
||||
|
||||
- [ ] `plan/plan.go` - plan queue implementation
|
||||
- [ ] Store planned tasks/goals
|
||||
|
|
@ -1100,13 +1293,15 @@ func TestWithLLM(t *testing.T) {
|
|||
| 7. P1 Goals | ✅ | Goal Generation Agent integration |
|
||||
| 8. P2 Tasks | ✅ | Task Planning Agent integration |
|
||||
| 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation |
|
||||
| 10. P4 Delivery | ⬜ | Output delivery (email/file/webhook/notify) |
|
||||
| 11. P5 Learning | ⬜ | Learning Agent + KB save |
|
||||
| 12. API & Integration | ⬜ | Complete API, end-to-end tests |
|
||||
| 13. Advanced | ⬜ | Semantic dedup, plan queue, Sandbox mode (requires container infrastructure) |
|
||||
| 10. P4 Delivery | ✅ | Output delivery (email/webhook/process, notify future) |
|
||||
| 11. API & Integration | ⬜ | Complete API, end-to-end tests (main flow: P0→P1→P2→P3→P4) |
|
||||
| 12. Advanced | ⬜ | P5 Learning, dedup, plan queue, Sandbox mode |
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -168,7 +168,11 @@ func (e *Executor) mockPhaseOutput(exec *robottypes.Execution, phase robottypes.
|
|||
}
|
||||
case robottypes.PhaseDelivery:
|
||||
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,
|
||||
}
|
||||
case robottypes.PhaseLearning:
|
||||
|
|
|
|||
|
|
@ -201,7 +201,11 @@ func (e *Executor) mockPhaseOutput(exec *robottypes.Execution, phase robottypes.
|
|||
}
|
||||
case robottypes.PhaseDelivery:
|
||||
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,
|
||||
}
|
||||
case robottypes.PhaseLearning:
|
||||
|
|
|
|||
|
|
@ -1,32 +1,397 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunDelivery executes P4: Delivery phase
|
||||
// Delivers results to configured targets
|
||||
// Calls the Delivery Agent to generate content, then routes to Delivery Center
|
||||
//
|
||||
// Input:
|
||||
// - TaskResults (from P3)
|
||||
// - Delivery config from robot
|
||||
// - Full execution context (P0-P3)
|
||||
// - Robot config
|
||||
//
|
||||
// Output:
|
||||
// - DeliveryResult with success status
|
||||
// - DeliveryResult with content and channel results
|
||||
//
|
||||
// Delivery Types:
|
||||
// - DeliveryEmail: Send email
|
||||
// - DeliveryNotify: Send notification
|
||||
// - DeliveryWebhook: Call webhook
|
||||
// - DeliveryStore: Store to database
|
||||
//
|
||||
// TODO: Implement real delivery
|
||||
// Process:
|
||||
// 1. Call Delivery Agent with full execution context
|
||||
// 2. Agent generates DeliveryContent (summary, body, attachments)
|
||||
// 3. Route content to Delivery Center for actual delivery
|
||||
func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
||||
e.simulateStreamDelay()
|
||||
|
||||
exec.Delivery = &robottypes.DeliveryResult{
|
||||
Type: robottypes.DeliveryNotify,
|
||||
Success: true,
|
||||
// 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{
|
||||
RequestID: generateRequestID(exec.ID),
|
||||
Content: &robottypes.DeliveryContent{
|
||||
Summary: truncateSummary(content, 200),
|
||||
Body: content,
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
// 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/job"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Executor implements the standard executor with real Agent calls
|
||||
// This is the production executor that:
|
||||
// - Creates Job records for tracking
|
||||
// - Persists execution history to database
|
||||
// - Calls real Agents via Assistant.Stream()
|
||||
// - Logs phase transitions and errors
|
||||
type Executor struct {
|
||||
config types.Config
|
||||
store *store.ExecutionStore
|
||||
execCount atomic.Int32
|
||||
currentCount atomic.Int32
|
||||
onStart func()
|
||||
|
|
@ -25,13 +28,16 @@ type Executor struct {
|
|||
|
||||
// New creates a new standard executor
|
||||
func New() *Executor {
|
||||
return &Executor{}
|
||||
return &Executor{
|
||||
store: store.NewExecutionStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithConfig creates a new standard executor with configuration
|
||||
func NewWithConfig(config types.Config) *Executor {
|
||||
return &Executor{
|
||||
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
|
||||
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
|
||||
if !robot.TryAcquireSlot(exec) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
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 {
|
||||
_ = 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
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +155,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
if !e.config.SkipJobIntegration {
|
||||
_ = 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
|
@ -183,6 +225,19 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
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 {
|
||||
e.config.OnPhaseEnd(phase)
|
||||
}
|
||||
|
|
@ -195,6 +250,26 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
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
|
||||
func (e *Executor) ExecCount() int {
|
||||
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 {
|
||||
switch t {
|
||||
case robottypes.DeliveryEmail, robottypes.DeliveryWebhook,
|
||||
robottypes.DeliveryFile, robottypes.DeliveryNotify:
|
||||
robottypes.DeliveryProcess, robottypes.DeliveryNotify:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ func TestParseDeliveryFromGoalsResponse(t *testing.T) {
|
|||
if exec.Goals.Delivery.Type != "" {
|
||||
validTypes := []types.DeliveryType{
|
||||
types.DeliveryEmail, types.DeliveryWebhook,
|
||||
types.DeliveryFile, types.DeliveryNotify,
|
||||
types.DeliveryProcess, types.DeliveryNotify,
|
||||
}
|
||||
found := false
|
||||
for _, vt := range validTypes {
|
||||
|
|
@ -355,7 +355,7 @@ func TestDeliveryTypeValidation(t *testing.T) {
|
|||
validTypes := []types.DeliveryType{
|
||||
types.DeliveryEmail,
|
||||
types.DeliveryWebhook,
|
||||
types.DeliveryFile,
|
||||
types.DeliveryProcess,
|
||||
types.DeliveryNotify,
|
||||
}
|
||||
|
||||
|
|
@ -462,7 +462,7 @@ func TestParseDelivery(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 {
|
||||
data := map[string]interface{}{
|
||||
|
|
|
|||
|
|
@ -511,12 +511,17 @@ func (f *InputFormatter) FormatExecutionSummary(exec *robottypes.Execution) stri
|
|||
// Delivery (P4)
|
||||
if exec.Delivery != nil {
|
||||
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 {
|
||||
sb.WriteString("- **Status**: ✓ Success\n")
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -455,7 +455,11 @@ func TestInputFormatterFormatExecutionSummary(t *testing.T) {
|
|||
{TaskID: "t2", Success: true, Duration: 200},
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
|
@ -476,7 +480,7 @@ func TestInputFormatterFormatExecutionSummary(t *testing.T) {
|
|||
assert.Contains(t, result, "## Results (P3)")
|
||||
assert.Contains(t, result, "✓ t1")
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ type Config struct {
|
|||
// SkipJobIntegration skips job system integration (for testing)
|
||||
SkipJobIntegration bool
|
||||
|
||||
// SkipPersistence skips execution record persistence (for testing)
|
||||
SkipPersistence bool
|
||||
|
||||
// OnPhaseStart callback when a phase starts
|
||||
OnPhaseStart func(phase robottypes.Phase)
|
||||
|
||||
|
|
|
|||
|
|
@ -278,8 +278,12 @@ func TestCompleteExecution(t *testing.T) {
|
|||
|
||||
// Simulate execution progress
|
||||
exec.Delivery = &types.DeliveryResult{
|
||||
RequestID: "test-delivery-001",
|
||||
Content: &types.DeliveryContent{
|
||||
Summary: "Test delivery completed",
|
||||
Body: "# Test Delivery\n\nThis is a test delivery result.",
|
||||
},
|
||||
Success: true,
|
||||
Type: types.DeliveryEmail,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,17 +7,17 @@ import (
|
|||
|
||||
// Config - robot_config in __yao.member
|
||||
type Config struct {
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *Delivery `json:"delivery,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // delivery preferences (see robot.go)
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
|
||||
}
|
||||
|
||||
// ExecutorConfig - executor settings
|
||||
|
|
@ -224,12 +224,6 @@ type MCPConfig struct {
|
|||
Tools []string `json:"tools,omitempty"` // empty = all
|
||||
}
|
||||
|
||||
// Delivery - output delivery
|
||||
type Delivery struct {
|
||||
Type DeliveryType `json:"type"`
|
||||
Opts map[string]interface{} `json:"opts,omitempty"`
|
||||
}
|
||||
|
||||
// Event - event trigger config
|
||||
type Event struct {
|
||||
Type EventSource `json:"type"` // webhook | database
|
||||
|
|
|
|||
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
|
||||
const (
|
||||
DeliveryEmail DeliveryType = "email"
|
||||
DeliveryFile DeliveryType = "file"
|
||||
DeliveryWebhook DeliveryType = "webhook"
|
||||
DeliveryNotify DeliveryType = "notify"
|
||||
DeliveryEmail DeliveryType = "email" // Send via yao/messenger
|
||||
DeliveryWebhook DeliveryType = "webhook" // POST to external URL
|
||||
DeliveryProcess DeliveryType = "process" // Call Yao Process
|
||||
DeliveryNotify DeliveryType = "notify" // In-app notification (future, auto by subscriptions)
|
||||
)
|
||||
|
||||
// DedupResult - deduplication result
|
||||
|
|
|
|||
|
|
@ -83,8 +83,8 @@ func TestPriorityEnum(t *testing.T) {
|
|||
|
||||
func TestDeliveryTypeEnum(t *testing.T) {
|
||||
assert.Equal(t, types.DeliveryType("email"), types.DeliveryEmail)
|
||||
assert.Equal(t, types.DeliveryType("file"), types.DeliveryFile)
|
||||
assert.Equal(t, types.DeliveryType("webhook"), types.DeliveryWebhook)
|
||||
assert.Equal(t, types.DeliveryType("process"), types.DeliveryProcess)
|
||||
assert.Equal(t, types.DeliveryType("notify"), types.DeliveryNotify)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type Robot struct {
|
|||
SystemPrompt string `json:"system_prompt"`
|
||||
Status RobotStatus `json:"robot_status"`
|
||||
AutonomousMode bool `json:"autonomous_mode"`
|
||||
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
|
||||
|
||||
// Parsed config (from robot_config JSON field)
|
||||
Config *Config `json:"-"`
|
||||
|
|
@ -262,15 +263,100 @@ type ValidationResult struct {
|
|||
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 DeliveryType `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Recipients []string `json:"recipients,omitempty"` // who received
|
||||
Content string `json:"content,omitempty"` // formatted content that was delivered
|
||||
Details interface{} `json:"details,omitempty"` // channel-specific response
|
||||
Error string `json:"error,omitempty"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -297,6 +383,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
|
|||
DisplayName: getString(m, "display_name"),
|
||||
SystemPrompt: getString(m, "system_prompt"),
|
||||
AutonomousMode: getBool(m, "autonomous_mode"),
|
||||
RobotEmail: getString(m, "robot_email"),
|
||||
}
|
||||
|
||||
// Parse robot_status
|
||||
|
|
|
|||
|
|
@ -536,25 +536,231 @@ func TestValidationResultMultiTurnFields(t *testing.T) {
|
|||
func TestDeliveryResultStructure(t *testing.T) {
|
||||
sentAt := time.Now()
|
||||
delivery := &types.DeliveryResult{
|
||||
Type: types.DeliveryEmail,
|
||||
Success: true,
|
||||
Recipients: []string{"user@example.com", "manager@example.com"},
|
||||
Content: "# Weekly Report\n\nSales increased by 20%...",
|
||||
Details: map[string]interface{}{
|
||||
"message_id": "msg-12345",
|
||||
"subject": "Daily Report",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
SentAt: &sentAt,
|
||||
Results: []types.ChannelResult{
|
||||
{
|
||||
Type: types.DeliveryEmail,
|
||||
Target: "user@example.com",
|
||||
Success: true,
|
||||
Details: map[string]interface{}{
|
||||
"message_id": "msg-12345",
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: types.DeliveryWebhook,
|
||||
Target: "https://webhook.example.com/notify",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
Success: true,
|
||||
SentAt: &sentAt,
|
||||
}
|
||||
|
||||
assert.Equal(t, types.DeliveryEmail, delivery.Type)
|
||||
assert.Equal(t, "req-12345", delivery.RequestID)
|
||||
assert.True(t, delivery.Success)
|
||||
assert.Len(t, delivery.Recipients, 2)
|
||||
assert.Contains(t, delivery.Content, "Weekly Report")
|
||||
assert.NotNil(t, delivery.Details)
|
||||
assert.NotNil(t, delivery.Content)
|
||||
assert.Equal(t, "Weekly sales report completed", delivery.Content.Summary)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
delivery := &types.DeliveryTarget{
|
||||
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 (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"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
|
||||
if message.HTML != "" {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// e.g., "John Doe <john@example.com>" -> "john@example.com"
|
||||
func extractEmailAddress(address string) string {
|
||||
|
|
|
|||
|
|
@ -768,3 +768,156 @@ func TestProvider_TriggerWebhook(t *testing.T) {
|
|||
assert.Nil(t, msg)
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -218,6 +221,17 @@ func (p *Provider) Close() error {
|
|||
func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error {
|
||||
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
|
||||
data := url.Values{}
|
||||
|
||||
|
|
@ -295,3 +309,134 @@ func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error
|
|||
|
||||
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
|
||||
event := ginCtx.Request.FormValue("event")
|
||||
recipient := ginCtx.Request.FormValue("recipient")
|
||||
messageId := ginCtx.Request.FormValue("message-id")
|
||||
messageID := ginCtx.Request.FormValue("message-id")
|
||||
timestamp := ginCtx.Request.FormValue("timestamp")
|
||||
token := ginCtx.Request.FormValue("token")
|
||||
signature := ginCtx.Request.FormValue("signature")
|
||||
|
|
@ -38,8 +38,8 @@ func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) {
|
|||
if recipient != "" {
|
||||
message.To = []string{recipient}
|
||||
}
|
||||
if messageId != "" {
|
||||
message.Metadata["message_id"] = messageId
|
||||
if messageID != "" {
|
||||
message.Metadata["message_id"] = messageID
|
||||
}
|
||||
|
||||
// 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{
|
||||
"__yao.agent.assistant": "yao/models/agent/assistant.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.resume": "yao/models/agent/resume.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{
|
||||
"__yao.agent.assistant": "yao/models/agent/assistant.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.resume": "yao/models/agent/resume.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