Merge pull request #1423 from trheyi/main

Refactor Delivery Structures and Enhance Documentation
This commit is contained in:
Max 2026-01-18 18:39:07 +08:00 committed by GitHub
commit 46a6bc1177
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 6065 additions and 337 deletions

View file

@ -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

View file

@ -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"
}
```

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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()
}

View 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
}

File diff suppressed because it is too large Load diff

View file

@ -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())

View 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"},
},
},
}
}

View file

@ -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

View file

@ -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{}{

View file

@ -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")
}

View file

@ -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) {

View file

@ -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)

View file

@ -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)

View 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
}

View 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)
}
}

View file

@ -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

View 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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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")
}
}

View file

@ -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",

View file

@ -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",

View 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 }
}