// Package webhook delivers HTTP POST notifications on task/workflow completion. // // 10s timeout per attempt. package webhook import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "time" ) const ( maxAttempts = 3 timeout = 10 * time.Second signatureHeader = "X-Armature-Signature" ) // Payload is the JSON body sent to webhook endpoints. type Payload struct { TaskID string `json:"task_id,omitempty"` RunID string `json:"run_id,omitempty"` TaskName string `json:"task_name,omitempty"` WorkflowID string `json:"workflow_id,omitempty"` Status string `json:"status"` CompletedAt time.Time `json:"completed_at"` Output string `json:"output,omitempty"` // last assistant message or relay payload TokensUsed int `json:"tokens_used,omitempty"` // total tokens consumed StageData any `json:"stage_data,omitempty"` // workflow stage data (if applicable) Error string `json:"error,omitempty"` } // Deliver sends a webhook payload to the given URL with HMAC signing. // Retries up to 3 times with exponential backoff (1s, 5s, 25s). // Runs synchronously — call in a goroutine for non-blocking delivery. func Deliver(url, secret string, payload Payload) error { if url == "" { return nil } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("webhook marshal: %w", err) } // Compute HMAC signature signature := "" if secret != "" { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) signature = hex.EncodeToString(mac.Sum(nil)) } client := &http.Client{Timeout: timeout} backoff := time.Second // 1s, 5s, 25s var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("webhook request build: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Armature-Webhook/1.0") if signature != "" { req.Header.Set(signatureHeader, signature) } resp, err := client.Do(req) if err != nil { lastErr = err log.Printf("[webhook] Attempt %d/%d failed for %s: %v", attempt, maxAttempts, url, err) } else { io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { if attempt > 1 { log.Printf("[webhook] Delivered to %s on attempt %d", url, attempt) } return nil } lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) log.Printf("[webhook] Attempt %d/%d returned %d for %s", attempt, maxAttempts, resp.StatusCode, url) } if attempt < maxAttempts { time.Sleep(backoff) backoff *= 5 } } return fmt.Errorf("webhook delivery failed after %d attempts: %w", maxAttempts, lastErr) } // GenerateSecret creates a random 32-byte hex-encoded webhook secret. func GenerateSecret() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { // Fallback to timestamp-based (extremely unlikely) return fmt.Sprintf("%x", time.Now().UnixNano()) } return hex.EncodeToString(b) }