This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/webhook/webhook.go
Jeffrey Smith e02b13dc12
All checks were successful
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m55s
Feat v0.7.6 code hygiene (#60)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 17:39:41 +00:00

112 lines
3.1 KiB
Go

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