Files
synapse/internal/transport/multipart_polling.go
2026-06-28 03:24:26 +08:00

612 lines
17 KiB
Go

package transport
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"sync"
"time"
"synapse/pkg/types"
)
// =============================================================================
// Multipart Transport - 混合文本+文件传输
// =============================================================================
// MultipartTransport handles multipart file transfers
type MultipartTransport struct {
mu sync.RWMutex
client *http.Client
timeout time.Duration
authSecret string
}
// NewMultipartTransport creates a new multipart transport
func NewMultipartTransport(timeout time.Duration, authSecret string) *MultipartTransport {
return &MultipartTransport{
client: &http.Client{
Timeout: timeout,
},
timeout: timeout,
authSecret: authSecret,
}
}
// =============================================================================
// Multipart Message Sending
// =============================================================================
// SendTaskWithFiles sends a task request with file attachments via multipart/form-data
func (m *MultipartTransport) SendTaskWithFiles(
ctx context.Context,
endpoint string,
msg *types.UnifiedSynapseMessage,
files []types.FileContent,
) error {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// 1. Write the main request JSON as the "request" part
requestPart, err := writer.CreateFormField("request")
if err != nil {
return fmt.Errorf("failed to create request part: %w", err)
}
if err := json.NewEncoder(requestPart).Encode(msg); err != nil {
return fmt.Errorf("failed to encode request: %w", err)
}
// 2. Write each file as a separate part
for _, file := range files {
if file.TransferMode != types.TransferInline {
continue // Skip URL-based files
}
part, err := writer.CreateFormFile(file.Name, file.Name)
if err != nil {
return fmt.Errorf("failed to create file part %s: %w", file.Name, err)
}
// For inline files, data is base64 encoded in file.Data
// In real implementation, decode base64 and write binary
// For now, just write the data as-is
if file.Data != "" {
part.Write([]byte(file.Data))
}
}
// Close the writer to finalize the boundary
if err := writer.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
// 3. Create HTTP request
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, &buf)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-Synapse-Version", "1.0.0")
req.Header.Set("X-Synapse-Agent", msg.Sender.AgentID)
req.Header.Set("X-Synapse-System", msg.Sender.SystemID)
// 4. Send
resp, err := m.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
log.Printf("[Multipart] Task sent with %d files to %s", len(files), endpoint)
return nil
}
// =============================================================================
// Multipart Message Parsing
// =============================================================================
// ParseMultipartRequest parses a multipart request with message + files
func (m *MultipartTransport) ParseMultipartRequest(r *http.Request) (
*types.UnifiedSynapseMessage,
[]types.FileContent,
error,
) {
// Limit max size (100MB default)
if err := r.ParseMultipartForm(100 << 20); err != nil {
return nil, nil, fmt.Errorf("failed to parse multipart: %w", err)
}
// 1. Parse the message JSON
requestValue := r.FormValue("request")
if requestValue == "" {
return nil, nil, fmt.Errorf("missing 'request' field")
}
var msg types.UnifiedSynapseMessage
if err := json.Unmarshal([]byte(requestValue), &msg); err != nil {
return nil, nil, fmt.Errorf("invalid request JSON: %w", err)
}
// 2. Parse file attachments
var files []types.FileContent
for _, fileHeaders := range r.MultipartForm.File {
for _, fh := range fileHeaders {
file, err := fh.Open()
if err != nil {
log.Printf("[Multipart] Failed to open file %s: %v", fh.Filename, err)
continue
}
data, err := io.ReadAll(file)
file.Close()
if err != nil {
log.Printf("[Multipart] Failed to read file %s: %v", fh.Filename, err)
continue
}
mimeType := fh.Header.Get("Content-Type")
if mimeType == "" {
mimeType = types.InferMimeType(fh.Filename)
}
fc := types.FileContent{
ContentBase: types.ContentBase{
MIMEType: mimeType,
Encoding: types.EncodingBase64,
Size: fh.Size,
TransferMode: types.TransferInline,
},
Name: fh.Filename,
Data: string(data), // In real impl: base64 encode
}
files = append(files, fc)
}
}
log.Printf("[Multipart] Parsed request: %d files received", len(files))
return &msg, files, nil
}
// =============================================================================
// Polling Client - 轮询客户端
// =============================================================================
// PollingClient handles task status polling
type PollingClient struct {
mu sync.RWMutex
client *http.Client
baseURL string
authSecret string
// Polling configuration
initialInterval time.Duration
maxInterval time.Duration
backoffFactor float64
maxDuration time.Duration
// Active polls
activePolls map[string]*pollContext
}
type pollContext struct {
taskID string
ctx context.Context
cancel context.CancelFunc
interval time.Duration
lastStatus string
lastProgress int
callback func(status *types.TaskInfo) bool // returns true to stop polling
}
// PollingClientConfig holds configuration for the polling client
type PollingClientConfig struct {
BaseURL string
AuthSecret string
InitialInterval time.Duration
MaxInterval time.Duration
BackoffFactor float64
MaxDuration time.Duration
Timeout time.Duration
}
// DefaultPollingClientConfig returns default polling configuration
func DefaultPollingClientConfig() *PollingClientConfig {
return &PollingClientConfig{
InitialInterval: 5 * time.Second,
MaxInterval: 60 * time.Second,
BackoffFactor: 2.0,
MaxDuration: 2 * time.Hour,
Timeout: 30 * time.Second,
}
}
// NewPollingClient creates a new polling client
func NewPollingClient(config *PollingClientConfig) *PollingClient {
if config == nil {
config = DefaultPollingClientConfig()
}
return &PollingClient{
client: &http.Client{
Timeout: config.Timeout,
},
baseURL: config.BaseURL,
authSecret: config.AuthSecret,
initialInterval: config.InitialInterval,
maxInterval: config.MaxInterval,
backoffFactor: config.BackoffFactor,
maxDuration: config.MaxDuration,
activePolls: make(map[string]*pollContext),
}
}
// =============================================================================
// Single Poll - 单次轮询
// =============================================================================
// GetTaskStatus polls for a task's current status
func (p *PollingClient) GetTaskStatus(ctx context.Context, taskID string) (*types.TaskInfo, error) {
url := fmt.Sprintf("%s/synapse/task/%s", p.baseURL, taskID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Synapse-Version", "1.0.0")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 304 {
return nil, nil // Not modified
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result types.TaskStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if !result.Success {
return nil, fmt.Errorf("API error: %s", result.Error.Message)
}
return result.Task, nil
}
// ListTasks lists tasks with filtering
func (p *PollingClient) ListTasks(ctx context.Context, status string, limit, offset int) (*types.TaskListResponse, error) {
url := fmt.Sprintf("%s/synapse/tasks?status=%s&limit=%d&offset=%d",
p.baseURL, status, limit, offset)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
var result types.TaskListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetTaskEvents gets incremental task events
func (p *PollingClient) GetTaskEvents(ctx context.Context, taskID, since string) (*types.TaskEventsResponse, error) {
url := fmt.Sprintf("%s/synapse/task/%s/events", p.baseURL, taskID)
if since != "" {
url += fmt.Sprintf("?since=%s", since)
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
var result types.TaskEventsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// =============================================================================
// Continuous Polling - 持续轮询
// =============================================================================
// StartPolling starts continuous polling for a task
func (p *PollingClient) StartPolling(
ctx context.Context,
taskID string,
callback func(status *types.TaskInfo) bool,
) error {
p.mu.Lock()
if _, exists := p.activePolls[taskID]; exists {
p.mu.Unlock()
return fmt.Errorf("polling already active for task %s", taskID)
}
pollCtx, cancel := context.WithTimeout(ctx, p.maxDuration)
pc := &pollContext{
taskID: taskID,
ctx: pollCtx,
cancel: cancel,
interval: p.initialInterval,
callback: callback,
}
p.activePolls[taskID] = pc
p.mu.Unlock()
go p.pollLoop(pc)
return nil
}
// StopPolling stops polling for a task
func (p *PollingClient) StopPolling(taskID string) {
p.mu.Lock()
defer p.mu.Unlock()
if pc, exists := p.activePolls[taskID]; exists {
pc.cancel()
delete(p.activePolls, taskID)
}
}
// pollLoop is the main polling loop
func (p *PollingClient) pollLoop(pc *pollContext) {
defer func() {
p.mu.Lock()
delete(p.activePolls, pc.taskID)
p.mu.Unlock()
}()
ticker := time.NewTicker(pc.interval)
defer ticker.Stop()
consecutiveNoChange := 0
for {
select {
case <-pc.ctx.Done():
log.Printf("[Polling] Stopped for task %s: %v", pc.taskID, pc.ctx.Err())
return
case <-ticker.C:
status, err := p.GetTaskStatus(pc.ctx, pc.taskID)
if err != nil {
log.Printf("[Polling] Error for task %s: %v", pc.taskID, err)
continue
}
if status == nil {
continue
}
// Check if status or progress changed
changed := status.Status != pc.lastStatus || status.Progress != pc.lastProgress
if changed {
consecutiveNoChange = 0
pc.interval = p.initialInterval
ticker.Reset(pc.interval)
} else {
consecutiveNoChange++
// Apply backoff after 3 consecutive no-change polls
if consecutiveNoChange >= 3 {
newInterval := time.Duration(float64(pc.interval) * p.backoffFactor)
if newInterval > p.maxInterval {
newInterval = p.maxInterval
}
if newInterval != pc.interval {
pc.interval = newInterval
ticker.Reset(pc.interval)
log.Printf("[Polling] Backoff to %v for task %s", pc.interval, pc.taskID)
}
}
}
pc.lastStatus = status.Status
pc.lastProgress = status.Progress
// Call callback
if pc.callback(status) {
log.Printf("[Polling] Callback returned stop for task %s", pc.taskID)
return
}
// Check terminal states
if status.Status == "completed" || status.Status == "failed" ||
status.Status == "cancelled" || status.Status == "rejected" {
log.Printf("[Polling] Task %s reached terminal state: %s", pc.taskID, status.Status)
return
}
}
}
}
// =============================================================================
// Capability Query - 能力查询(同步)
// =============================================================================
// QueryCapabilities queries an agent's capabilities synchronously
func (p *PollingClient) QueryCapabilities(ctx context.Context, baseURL string) (*types.AgentCapabilities, error) {
url := fmt.Sprintf("%s/synapse/capabilities", baseURL)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var result types.CapabilitiesResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if !result.Success {
return nil, fmt.Errorf("API error: %s", result.Error.Message)
}
return result.Data, nil
}
// HealthCheck checks if an agent is reachable
func (p *PollingClient) HealthCheck(ctx context.Context, baseURL string) (bool, error) {
url := fmt.Sprintf("%s/synapse/health", baseURL)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return false, fmt.Errorf("failed to create request: %w", err)
}
resp, err := p.client.Do(req)
if err != nil {
return false, nil
}
defer resp.Body.Close()
return resp.StatusCode == 200, nil
}
// =============================================================================
// Hybrid Mode - 混合模式 (Callback + Polling Fallback)
// =============================================================================
// HybridTransport combines callback-based push with polling-based fallback
type HybridTransport struct {
callbackTransport *HTTPTransport
pollingClient *PollingClient
mu sync.RWMutex
pendingTasks map[string]*hybridTaskState
}
type hybridTaskState struct {
taskID string
callbackOK bool
pollingActive bool
lastStatus string
}
// NewHybridTransport creates a new hybrid transport
func NewHybridTransport(callback *HTTPTransport, polling *PollingClient) *HybridTransport {
return &HybridTransport{
callbackTransport: callback,
pollingClient: polling,
pendingTasks: make(map[string]*hybridTaskState),
}
}
// SendTaskWithHybridFallback sends a task request and sets up both callback and polling
func (h *HybridTransport) SendTaskWithHybridFallback(
ctx context.Context,
endpoint string,
msg *types.UnifiedSynapseMessage,
callback func(status *types.TaskInfo),
) error {
// 1. First, try callback-based approach
err := h.callbackTransport.Send(ctx, endpoint, msg)
if err != nil {
log.Printf("[Hybrid] Direct send failed, using polling only: %v", err)
}
// 2. Extract task ID from payload
var payload types.TaskRequestPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return fmt.Errorf("failed to extract task ID: %w", err)
}
taskID := payload.Task.ID
// 3. Set up polling as fallback after 30s
h.mu.Lock()
h.pendingTasks[taskID] = &hybridTaskState{
taskID: taskID,
callbackOK: err == nil,
pollingActive: false,
}
h.mu.Unlock()
// Start polling after a delay if callback hasn't been received
go func() {
select {
case <-time.After(30 * time.Second):
h.mu.RLock()
state, exists := h.pendingTasks[taskID]
h.mu.RUnlock()
if exists && !state.callbackOK {
log.Printf("[Hybrid] Callback not received for %s, starting polling fallback", taskID)
h.pollingClient.StartPolling(ctx, taskID, func(status *types.TaskInfo) bool {
if callback != nil {
callback(status)
}
return false
})
}
case <-ctx.Done():
return
}
}()
return nil
}
// NotifyCallbackReceived notifies that a callback was received (stops polling fallback)
func (h *HybridTransport) NotifyCallbackReceived(taskID string) {
h.mu.Lock()
defer h.mu.Unlock()
if state, exists := h.pendingTasks[taskID]; exists {
state.callbackOK = true
if state.pollingActive {
h.pollingClient.StopPolling(taskID)
state.pollingActive = false
}
log.Printf("[Hybrid] Callback received for %s, polling disabled", taskID)
}
}