612 lines
17 KiB
Go
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)
|
|
}
|
|
}
|