All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-sqlite (pull_request) Successful in 2m54s
CI/CD / test-go-pg (pull_request) Successful in 2m56s
CI/CD / build-and-deploy (pull_request) Successful in 1m1s
Clean up channels-era dead code, align migrations, add 92 tests, and fix backup download + admin storage tab bugs before v0.8.x kernel expansion. Critical fixes: - Remove `channels` from allowedViews in db_module.go - Fix 5 dead API routes in workflow.html → public workflow API - Fix RenderWorkflow handler to use route param as entry token Dead code removal: - Delete SeedTestChannel(), RunContext.ChannelID, webhook.ChannelID - Fix stale comments in storage.go, prometheus.go, workflow_module.go Migration hygiene: - Add SQLite placeholder 013_cluster_registry.sql, renumber to 014 - Compat rename in migrate.go for existing SQLite databases - Document missing migration 008 in both 009 files Test coverage: - 82 workflow routing tests (all operators, branch rules, stage resolution) - 10 middleware tests (permissions, admin, rate limiter) Bug fixes: - Remove dead Admin Storage tab from System category - Fix backup download: sw.auth.token() → sw.auth._getToken() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
112 lines
3.1 KiB
Go
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)
|
|
}
|