merge: webhooks & Kubernetes Job runner
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ vendor/
|
||||
|
||||
# Data directory
|
||||
data/
|
||||
data-*/
|
||||
|
||||
# Node / Svelte
|
||||
web/node_modules/
|
||||
|
||||
@@ -93,6 +93,8 @@ make lint # Run linters
|
||||
## Active Technologies
|
||||
- Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI) (002-mcp-auth-ux-polish)
|
||||
- modernc.org/sqlite (pure Go), TFMV/hnsw (vectors) (002-mcp-auth-ux-polish)
|
||||
- Go 1.25+ (from go.mod) + mark3labs/mcp-go (MCP tools), go-chi/chi (HTTP), golang.org/x/time/rate (rate limiting), k8s.io/client-go (K8s Jobs — optional) (003-webhooks-k8s-runner)
|
||||
- modernc.org/sqlite (pure Go), migration 009_webhooks.sql (003-webhooks-k8s-runner)
|
||||
|
||||
## Recent Changes
|
||||
- 002-mcp-auth-ux-polish: Added Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI)
|
||||
|
||||
+48
-9
@@ -31,7 +31,9 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/auth"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/console"
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
"github.com/synapbus/synapbus/internal/health"
|
||||
k8spkg "github.com/synapbus/synapbus/internal/k8s"
|
||||
mcpserver "github.com/synapbus/synapbus/internal/mcp"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
prommetrics "github.com/synapbus/synapbus/internal/metrics"
|
||||
@@ -40,19 +42,21 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"github.com/synapbus/synapbus/internal/web"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
)
|
||||
|
||||
// version is set at build time via -ldflags "-X main.version=..."
|
||||
var version = "dev"
|
||||
|
||||
var (
|
||||
host string
|
||||
port int
|
||||
dataDir string
|
||||
logLevel string
|
||||
metricsEnabled bool
|
||||
traceRetention string
|
||||
adminSocketPath string
|
||||
host string
|
||||
port int
|
||||
dataDir string
|
||||
logLevel string
|
||||
metricsEnabled bool
|
||||
traceRetention string
|
||||
adminSocketPath string
|
||||
webhookWorkers int
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -76,6 +80,7 @@ func main() {
|
||||
serveCmd.Flags().BoolVar(&metricsEnabled, "metrics", false, "Enable Prometheus metrics endpoint at /metrics")
|
||||
serveCmd.Flags().StringVar(&traceRetention, "trace-retention", "0", "Trace retention period (e.g. 30d, 90d, 0 for unlimited)")
|
||||
serveCmd.Flags().StringVar(&adminSocketPath, "admin-socket", "", "Admin Unix socket path (default: {data}/synapbus.sock)")
|
||||
serveCmd.Flags().IntVar(&webhookWorkers, "webhook-workers", 8, "Number of webhook delivery worker goroutines")
|
||||
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
|
||||
@@ -145,6 +150,9 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
if as := os.Getenv("SYNAPBUS_ADMIN_SOCKET"); as != "" {
|
||||
adminSocketPath = as
|
||||
}
|
||||
if ww := os.Getenv("SYNAPBUS_WEBHOOK_WORKERS"); ww != "" {
|
||||
fmt.Sscanf(ww, "%d", &webhookWorkers)
|
||||
}
|
||||
if adminSocketPath == "" {
|
||||
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
|
||||
}
|
||||
@@ -390,8 +398,36 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
apiKeyStore := apikeys.NewSQLiteStore(db.DB)
|
||||
apiKeyService := apikeys.NewService(apiKeyStore)
|
||||
|
||||
// Create MCP server (with swarm + attachment + search tools)
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, con)
|
||||
// Create webhook service
|
||||
allowHTTPWebhooks := os.Getenv("SYNAPBUS_ALLOW_HTTP_WEBHOOKS") == "true"
|
||||
allowPrivateNetworks := os.Getenv("SYNAPBUS_ALLOW_PRIVATE_NETWORKS") == "true"
|
||||
webhookStore := webhooks.NewSQLiteWebhookStore(db.DB)
|
||||
webhookService := webhooks.NewWebhookService(webhookStore, allowHTTPWebhooks, allowPrivateNetworks)
|
||||
rateLimiter := webhooks.NewAgentRateLimiter(60) // 60 deliveries/minute per agent
|
||||
|
||||
// Create delivery engine (webhook dispatcher)
|
||||
deliveryEngine := webhooks.NewDeliveryEngine(webhookService, rateLimiter, allowPrivateNetworks)
|
||||
deliveryEngine.Start()
|
||||
slog.Info("webhook delivery engine started")
|
||||
|
||||
// Create K8s job runner and service
|
||||
k8sRunner := k8spkg.NewJobRunner(slog.Default())
|
||||
k8sStore := k8spkg.NewSQLiteK8sStore(db.DB)
|
||||
k8sService := k8spkg.NewK8sService(k8sStore, k8sRunner)
|
||||
k8sDispatcher := k8spkg.NewK8sDispatcher(k8sStore, k8sRunner, slog.Default())
|
||||
|
||||
if k8sRunner.IsAvailable() {
|
||||
slog.Info("K8s job runner available")
|
||||
} else {
|
||||
slog.Info("K8s job runner not available (not in-cluster)")
|
||||
}
|
||||
|
||||
// Create event dispatcher (fans out to webhooks + K8s)
|
||||
eventDispatcher := dispatcher.NewMultiDispatcher(slog.Default(), deliveryEngine, k8sDispatcher)
|
||||
msgService.SetDispatcher(eventDispatcher)
|
||||
|
||||
// Create MCP server (with swarm + attachment + search + webhook + K8s tools)
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, con, webhookService, k8sService)
|
||||
startTime := time.Now()
|
||||
|
||||
// Start task expiry worker
|
||||
@@ -539,6 +575,9 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
// Stop admin socket
|
||||
adminServer.Stop()
|
||||
|
||||
// Stop webhook delivery engine
|
||||
deliveryEngine.Stop()
|
||||
|
||||
// Stop expiry worker
|
||||
expiryWorker.Stop()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/smart-mcp-proxy/synapbus
|
||||
repository: ghcr.io/synapbus/synapbus
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "latest"
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/time v0.9.0
|
||||
k8s.io/api v0.35.2
|
||||
k8s.io/apimachinery v0.35.2
|
||||
k8s.io/client-go v0.35.2
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
@@ -27,27 +31,37 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgraph-io/ristretto v1.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/gobuffalo/pop/v6 v6.1.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/renameio v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/goveralls v0.0.12 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
@@ -73,6 +87,7 @@ require (
|
||||
github.com/viterin/partial v1.1.0 // indirect
|
||||
github.com/viterin/vek v0.4.2 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
|
||||
@@ -88,22 +103,33 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.31.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.31.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
|
||||
google.golang.org/grpc v1.69.2 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
@@ -39,6 +39,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/TFMV/hnsw v0.4.0 h1:k61xD3V9LzzwUMDLaHCn+1PbvMbJj33KRdUPiUtuj7k=
|
||||
github.com/TFMV/hnsw v0.4.0/go.mod h1:YPCKBOTpl3KzZxYBTVbR+uH7US5HpprYkDLALt/bgTY=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
@@ -70,6 +72,7 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cristalhq/jwt/v4 v4.0.2 h1:g/AD3h0VicDamtlM70GWGElp8kssQEv+5wYd7L9WOhU=
|
||||
github.com/cristalhq/jwt/v4 v4.0.2/go.mod h1:HnYraSNKDRag1DZP92rYHyrjyQHnVEHPNqesmzs+miQ=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -81,6 +84,8 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WA
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -97,6 +102,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
@@ -107,13 +114,24 @@ github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQr
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/gobuffalo/attrs v1.0.3/go.mod h1:KvDJCE0avbufqS0Bw3UV7RQynESY0jjod+572ctX4t8=
|
||||
github.com/gobuffalo/envy v1.10.2/go.mod h1:qGAGwdvDsaEtPhfBzb3o0SfDea8ByGn9j8bKmVft9z8=
|
||||
github.com/gobuffalo/fizz v1.14.4/go.mod h1:9/2fGNXNeIFOXEEgTPJwiK63e44RjG+Nc4hfMm1ArGM=
|
||||
@@ -167,6 +185,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -179,6 +199,7 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
@@ -192,8 +213,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
|
||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU=
|
||||
github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk=
|
||||
@@ -204,8 +225,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
@@ -270,7 +291,10 @@ github.com/jandelgado/gcov2lcov v1.0.5 h1:rkBt40h0CVK4oCb8Dps950gvfd1rYvQ8+cWa34
|
||||
github.com/jandelgado/gcov2lcov v1.0.5/go.mod h1:NnSxK6TMlg1oGDBfGelGbjgorT5/L3cchlbtgFYZSss=
|
||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
@@ -290,6 +314,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -334,6 +359,12 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -344,6 +375,10 @@ github.com/nyaruka/phonenumbers v1.1.6 h1:DcueYq7QrOArAprAYNoQfDgp0KetO4LqtnBtQC
|
||||
github.com/nyaruka/phonenumbers v1.1.6/go.mod h1:yShPJHDSH3aTKzCbXyVxNpbl2kA+F+Ne5Pun/MvFRos=
|
||||
github.com/oleiade/reflections v1.0.1 h1:D1XO3LVEYroYskEsoSiGItp9RUxG6jWnCVvrqH0HHQM=
|
||||
github.com/oleiade/reflections v1.0.1/go.mod h1:rdFxbxq4QXVZWj0F+e9jqjDkc7dbp97vkRixKo2JR60=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA=
|
||||
github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY=
|
||||
github.com/ory/fosite v0.49.0 h1:KNqO7RVt/1X8F08/UI0Y+GRvcpscCWgjqvpLBQPRovo=
|
||||
@@ -380,8 +415,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
@@ -449,6 +484,8 @@ github.com/viterin/vek v0.4.2 h1:Vyv04UjQT6gcjEFX82AS9ocgNbAJqsHviheIBdPlv5U=
|
||||
github.com/viterin/vek v0.4.2/go.mod h1:A4JRAe8OvbhdzBL5ofzjBS0J29FyUrf95tQogvtHHUc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -507,8 +544,9 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
@@ -703,6 +741,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -719,6 +759,8 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -887,7 +929,11 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -903,6 +949,18 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw=
|
||||
k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60=
|
||||
k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8=
|
||||
k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o=
|
||||
k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
@@ -934,3 +992,11 @@ modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/k8s"
|
||||
)
|
||||
|
||||
// K8sHandler handles REST API requests for K8s handlers and job runs.
|
||||
type K8sHandler struct {
|
||||
k8sService *k8s.K8sService
|
||||
k8sStore k8s.K8sStore
|
||||
agentService *agents.AgentService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewK8sHandler creates a new K8s handler.
|
||||
func NewK8sHandler(ks *k8s.K8sService, store k8s.K8sStore, agentService *agents.AgentService) *K8sHandler {
|
||||
return &K8sHandler{
|
||||
k8sService: ks,
|
||||
k8sStore: store,
|
||||
agentService: agentService,
|
||||
logger: slog.Default().With("component", "api.k8s"),
|
||||
}
|
||||
}
|
||||
|
||||
// ListHandlers handles GET /api/k8s/handlers?agent={name}.
|
||||
func (h *K8sHandler) ListHandlers(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
h.logger.Error("list agents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
|
||||
return
|
||||
}
|
||||
|
||||
agentFilter := r.URL.Query().Get("agent")
|
||||
|
||||
var allHandlers []*k8s.K8sHandler
|
||||
for _, agent := range ownedAgents {
|
||||
if agentFilter != "" && agent.Name != agentFilter {
|
||||
continue
|
||||
}
|
||||
handlers, err := h.k8sService.ListHandlers(r.Context(), agent.Name)
|
||||
if err != nil {
|
||||
h.logger.Error("list k8s handlers failed", "agent", agent.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
allHandlers = append(allHandlers, handlers...)
|
||||
}
|
||||
|
||||
if allHandlers == nil {
|
||||
allHandlers = []*k8s.K8sHandler{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"handlers": allHandlers,
|
||||
"total": len(allHandlers),
|
||||
})
|
||||
}
|
||||
|
||||
// ListJobRuns handles GET /api/k8s/job-runs?agent={name}&status={status}&limit={n}.
|
||||
func (h *K8sHandler) ListJobRuns(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
h.logger.Error("list agents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
|
||||
return
|
||||
}
|
||||
|
||||
agentFilter := r.URL.Query().Get("agent")
|
||||
status := r.URL.Query().Get("status")
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var allRuns []*k8s.K8sJobRun
|
||||
for _, agent := range ownedAgents {
|
||||
if agentFilter != "" && agent.Name != agentFilter {
|
||||
continue
|
||||
}
|
||||
runs, err := h.k8sService.GetJobRuns(r.Context(), agent.Name, status, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("list job runs failed", "agent", agent.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
allRuns = append(allRuns, runs...)
|
||||
}
|
||||
|
||||
if allRuns == nil {
|
||||
allRuns = []*k8s.K8sJobRun{}
|
||||
}
|
||||
|
||||
// Trim to limit
|
||||
if len(allRuns) > limit {
|
||||
allRuns = allRuns[:limit]
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"job_runs": allRuns,
|
||||
"total": len(allRuns),
|
||||
})
|
||||
}
|
||||
|
||||
// JobRunLogs handles GET /api/k8s/job-runs/{id}/logs.
|
||||
func (h *K8sHandler) JobRunLogs(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
runID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid job run ID"))
|
||||
return
|
||||
}
|
||||
|
||||
run, err := h.k8sStore.GetJobRunByID(r.Context(), runID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Job run not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.isAgentOwnedBy(r, run.AgentName, ownerID) {
|
||||
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this job run"))
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := h.k8sService.GetJobLogs(r.Context(), run.Namespace, run.JobName)
|
||||
if err != nil {
|
||||
h.logger.Error("get job logs failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get job logs"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"job_run": run,
|
||||
"logs": logs,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *K8sHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
|
||||
if agentName == "" {
|
||||
return false
|
||||
}
|
||||
agent, err := h.agentService.GetAgent(r.Context(), agentName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return agent.OwnerID == ownerID
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/apikeys"
|
||||
"github.com/synapbus/synapbus/internal/attachments"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
)
|
||||
|
||||
// RouterConfig holds optional services for the API router.
|
||||
@@ -24,6 +26,10 @@ type RouterConfig struct {
|
||||
ChannelService *channels.Service
|
||||
APIKeyService *apikeys.Service
|
||||
DeadLetterStore *messaging.DeadLetterStore
|
||||
WebhookService *webhooks.WebhookService
|
||||
WebhookStore webhooks.WebhookStore
|
||||
K8sService *k8s.K8sService
|
||||
K8sStore k8s.K8sStore
|
||||
SSEHub *SSEHub
|
||||
SessionMiddleware func(http.Handler) http.Handler
|
||||
}
|
||||
@@ -152,6 +158,31 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
||||
})
|
||||
}
|
||||
|
||||
// Webhooks
|
||||
if cfg.WebhookService != nil && cfg.WebhookStore != nil {
|
||||
webhooksHandler := NewWebhooksHandler(cfg.WebhookService, cfg.WebhookStore, cfg.AgentService)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
|
||||
r.Get("/api/webhooks", webhooksHandler.ListWebhooks)
|
||||
r.Get("/api/webhooks/{id}/deliveries", webhooksHandler.WebhookDeliveries)
|
||||
r.Get("/api/deliveries/dead-letters", webhooksHandler.DeadLetters)
|
||||
r.Post("/api/deliveries/{id}/retry", webhooksHandler.RetryDelivery)
|
||||
})
|
||||
}
|
||||
|
||||
// K8s Handlers
|
||||
if cfg.K8sService != nil && cfg.K8sStore != nil {
|
||||
k8sHandler := NewK8sHandler(cfg.K8sService, cfg.K8sStore, cfg.AgentService)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
|
||||
r.Get("/api/k8s/handlers", k8sHandler.ListHandlers)
|
||||
r.Get("/api/k8s/job-runs", k8sHandler.ListJobRuns)
|
||||
r.Get("/api/k8s/job-runs/{id}/logs", k8sHandler.JobRunLogs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics endpoint (unauthenticated, only registered when enabled)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
)
|
||||
|
||||
// WebhooksHandler handles REST API requests for webhooks and deliveries.
|
||||
type WebhooksHandler struct {
|
||||
webhookService *webhooks.WebhookService
|
||||
webhookStore webhooks.WebhookStore
|
||||
agentService *agents.AgentService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWebhooksHandler creates a new webhooks handler.
|
||||
func NewWebhooksHandler(ws *webhooks.WebhookService, store webhooks.WebhookStore, agentService *agents.AgentService) *WebhooksHandler {
|
||||
return &WebhooksHandler{
|
||||
webhookService: ws,
|
||||
webhookStore: store,
|
||||
agentService: agentService,
|
||||
logger: slog.Default().With("component", "api.webhooks"),
|
||||
}
|
||||
}
|
||||
|
||||
// ListWebhooks handles GET /api/webhooks?agent={name}.
|
||||
func (h *WebhooksHandler) ListWebhooks(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
h.logger.Error("list agents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
|
||||
return
|
||||
}
|
||||
|
||||
agentFilter := r.URL.Query().Get("agent")
|
||||
|
||||
var allWebhooks []*webhooks.Webhook
|
||||
for _, agent := range ownedAgents {
|
||||
if agentFilter != "" && agent.Name != agentFilter {
|
||||
continue
|
||||
}
|
||||
whs, err := h.webhookService.ListWebhooks(r.Context(), agent.Name)
|
||||
if err != nil {
|
||||
h.logger.Error("list webhooks failed", "agent", agent.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
allWebhooks = append(allWebhooks, whs...)
|
||||
}
|
||||
|
||||
if allWebhooks == nil {
|
||||
allWebhooks = []*webhooks.Webhook{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"webhooks": allWebhooks,
|
||||
"total": len(allWebhooks),
|
||||
})
|
||||
}
|
||||
|
||||
// WebhookDeliveries handles GET /api/webhooks/{id}/deliveries?status={status}&limit={n}.
|
||||
func (h *WebhooksHandler) WebhookDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
webhookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid webhook ID"))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the webhook to verify ownership
|
||||
wh, err := h.webhookStore.GetWebhookByID(r.Context(), webhookID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Webhook not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.isAgentOwnedBy(r, wh.AgentName, ownerID) {
|
||||
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this webhook"))
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
deliveries, err := h.webhookStore.GetDeliveriesByAgent(r.Context(), wh.AgentName, status, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("get deliveries failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get deliveries"))
|
||||
return
|
||||
}
|
||||
|
||||
// Filter deliveries to only those belonging to this webhook
|
||||
var filtered []*webhooks.WebhookDelivery
|
||||
for _, d := range deliveries {
|
||||
if d.WebhookID == webhookID {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
if filtered == nil {
|
||||
filtered = []*webhooks.WebhookDelivery{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"deliveries": filtered,
|
||||
"total": len(filtered),
|
||||
})
|
||||
}
|
||||
|
||||
// DeadLetters handles GET /api/deliveries/dead-letters?agent={name}&limit={n}.
|
||||
func (h *WebhooksHandler) DeadLetters(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
h.logger.Error("list agents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
|
||||
return
|
||||
}
|
||||
|
||||
agentFilter := r.URL.Query().Get("agent")
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var allDeadLetters []*webhooks.WebhookDelivery
|
||||
for _, agent := range ownedAgents {
|
||||
if agentFilter != "" && agent.Name != agentFilter {
|
||||
continue
|
||||
}
|
||||
deliveries, err := h.webhookStore.GetDeliveriesByAgent(r.Context(), agent.Name, webhooks.DeliveryStatusDeadLettered, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("get dead-lettered deliveries failed", "agent", agent.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
allDeadLetters = append(allDeadLetters, deliveries...)
|
||||
}
|
||||
|
||||
if allDeadLetters == nil {
|
||||
allDeadLetters = []*webhooks.WebhookDelivery{}
|
||||
}
|
||||
|
||||
// Trim to limit
|
||||
if len(allDeadLetters) > limit {
|
||||
allDeadLetters = allDeadLetters[:limit]
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"dead_letters": allDeadLetters,
|
||||
"total": len(allDeadLetters),
|
||||
})
|
||||
}
|
||||
|
||||
// RetryDelivery handles POST /api/deliveries/{id}/retry.
|
||||
func (h *WebhooksHandler) RetryDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
deliveryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid delivery ID"))
|
||||
return
|
||||
}
|
||||
|
||||
delivery, err := h.webhookStore.GetDeliveryByID(r.Context(), deliveryID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Delivery not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.isAgentOwnedBy(r, delivery.AgentName, ownerID) {
|
||||
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this delivery"))
|
||||
return
|
||||
}
|
||||
|
||||
if delivery.Status != webhooks.DeliveryStatusDeadLettered {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_status", "Only dead-lettered deliveries can be retried"))
|
||||
return
|
||||
}
|
||||
|
||||
// Reset delivery to pending for re-processing
|
||||
err = h.webhookStore.UpdateDeliveryStatus(r.Context(), deliveryID, webhooks.DeliveryStatusPending, 0, "", nil, nil)
|
||||
if err != nil {
|
||||
h.logger.Error("retry delivery failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to retry delivery"))
|
||||
return
|
||||
}
|
||||
|
||||
// Reset attempt counter
|
||||
if err := h.webhookStore.UpdateDeliveryAttempts(r.Context(), deliveryID, 0); err != nil {
|
||||
h.logger.Error("reset delivery attempts failed", "error", err)
|
||||
// Non-fatal: the status was already reset
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"retried": true})
|
||||
}
|
||||
|
||||
func (h *WebhooksHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
|
||||
if agentName == "" {
|
||||
return false
|
||||
}
|
||||
agent, err := h.agentService.GetAgent(r.Context(), agentName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return agent.OwnerID == ownerID
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// MessageEvent represents a messaging event to be dispatched to delivery mechanisms.
|
||||
type MessageEvent struct {
|
||||
EventType string // "message.received", "message.mentioned", "channel.message"
|
||||
MessageID int64
|
||||
FromAgent string
|
||||
ToAgent string // for DMs
|
||||
Channel string // for channel messages (empty for DMs)
|
||||
Body string
|
||||
Priority int
|
||||
Metadata string // JSON string
|
||||
Depth int // webhook chain depth (0 for original, incremented per hop)
|
||||
MentionedAgents []string // agents @mentioned in the body
|
||||
}
|
||||
|
||||
// EventDispatcher is the interface for delivering message events.
|
||||
type EventDispatcher interface {
|
||||
Dispatch(ctx context.Context, event MessageEvent) error
|
||||
}
|
||||
|
||||
// MultiDispatcher fans out events to multiple EventDispatchers.
|
||||
type MultiDispatcher struct {
|
||||
dispatchers []EventDispatcher
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewMultiDispatcher creates a MultiDispatcher that delivers events to each
|
||||
// provided dispatcher in sequence.
|
||||
func NewMultiDispatcher(logger *slog.Logger, dispatchers ...EventDispatcher) *MultiDispatcher {
|
||||
return &MultiDispatcher{
|
||||
dispatchers: dispatchers,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch sends the event to every registered dispatcher. Delivery is
|
||||
// best-effort: errors are logged but never returned so that message sending
|
||||
// is never blocked by a failing delivery mechanism.
|
||||
func (m *MultiDispatcher) Dispatch(ctx context.Context, event MessageEvent) error {
|
||||
for _, d := range m.dispatchers {
|
||||
if err := d.Dispatch(ctx, event); err != nil {
|
||||
m.logger.ErrorContext(ctx, "dispatcher failed",
|
||||
"event_type", event.EventType,
|
||||
"message_id", event.MessageID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mentionRe matches @agent-name patterns where the agent name consists of
|
||||
// alphanumeric characters, hyphens, and underscores.
|
||||
var mentionRe = regexp.MustCompile(`@([A-Za-z0-9][A-Za-z0-9_-]*)`)
|
||||
|
||||
// ExtractMentions returns a deduplicated list of agent names mentioned in body
|
||||
// via @agent-name syntax. The returned names do not include the @ prefix.
|
||||
func ExtractMentions(body string) []string {
|
||||
matches := mentionRe.FindAllStringSubmatch(body, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(matches))
|
||||
var result []string
|
||||
for _, m := range matches {
|
||||
name := m[1]
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
result = append(result, name)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockDispatcher records events it receives and optionally returns an error.
|
||||
type mockDispatcher struct {
|
||||
mu sync.Mutex
|
||||
events []MessageEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDispatcher) Dispatch(ctx context.Context, event MessageEvent) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.events = append(m.events, event)
|
||||
return m.err
|
||||
}
|
||||
|
||||
func (m *mockDispatcher) getEvents() []MessageEvent {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := make([]MessageEvent, len(m.events))
|
||||
copy(cp, m.events)
|
||||
return cp
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
func TestMultiDispatcher_DispatchesToAll(t *testing.T) {
|
||||
d1 := &mockDispatcher{}
|
||||
d2 := &mockDispatcher{}
|
||||
d3 := &mockDispatcher{}
|
||||
|
||||
md := NewMultiDispatcher(testLogger(), d1, d2, d3)
|
||||
ctx := context.Background()
|
||||
|
||||
event := MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 42,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "receiver",
|
||||
Body: "hello",
|
||||
}
|
||||
|
||||
err := md.Dispatch(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
|
||||
for i, d := range []*mockDispatcher{d1, d2, d3} {
|
||||
events := d.getEvents()
|
||||
if len(events) != 1 {
|
||||
t.Errorf("dispatcher %d: got %d events, want 1", i, len(events))
|
||||
continue
|
||||
}
|
||||
if events[0].MessageID != 42 {
|
||||
t.Errorf("dispatcher %d: message_id = %d, want 42", i, events[0].MessageID)
|
||||
}
|
||||
if events[0].FromAgent != "sender" {
|
||||
t.Errorf("dispatcher %d: from_agent = %q, want %q", i, events[0].FromAgent, "sender")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiDispatcher_ContinuesOnError(t *testing.T) {
|
||||
d1 := &mockDispatcher{err: errors.New("d1 failed")}
|
||||
d2 := &mockDispatcher{} // should still receive the event
|
||||
d3 := &mockDispatcher{err: errors.New("d3 failed")}
|
||||
|
||||
md := NewMultiDispatcher(testLogger(), d1, d2, d3)
|
||||
ctx := context.Background()
|
||||
|
||||
event := MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 99,
|
||||
FromAgent: "agent-x",
|
||||
ToAgent: "agent-y",
|
||||
Body: "test",
|
||||
}
|
||||
|
||||
// MultiDispatcher always returns nil (best-effort)
|
||||
err := md.Dispatch(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() should return nil even when dispatchers fail, got %v", err)
|
||||
}
|
||||
|
||||
// All three should have received the event
|
||||
for i, d := range []*mockDispatcher{d1, d2, d3} {
|
||||
events := d.getEvents()
|
||||
if len(events) != 1 {
|
||||
t.Errorf("dispatcher %d: got %d events, want 1 (should receive event even if it returns error)", i, len(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiDispatcher_EmptyDispatchers(t *testing.T) {
|
||||
md := NewMultiDispatcher(testLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
event := MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 1,
|
||||
}
|
||||
|
||||
err := md.Dispatch(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() with no dispatchers should not error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMentions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "single mention",
|
||||
body: "Hello @agent-1, how are you?",
|
||||
want: []string{"agent-1"},
|
||||
},
|
||||
{
|
||||
name: "multiple mentions",
|
||||
body: "@alice please talk to @bob about this",
|
||||
want: []string{"alice", "bob"},
|
||||
},
|
||||
{
|
||||
name: "duplicate mentions deduplicated",
|
||||
body: "@alice said hello, and @alice said goodbye",
|
||||
want: []string{"alice"},
|
||||
},
|
||||
{
|
||||
name: "mentions with underscores",
|
||||
body: "cc @my_agent_123",
|
||||
want: []string{"my_agent_123"},
|
||||
},
|
||||
{
|
||||
name: "mention with hyphen",
|
||||
body: "ask @code-reviewer",
|
||||
want: []string{"code-reviewer"},
|
||||
},
|
||||
{
|
||||
name: "no mentions",
|
||||
body: "this is a plain message with no mentions",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
body: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "mention at start",
|
||||
body: "@first is mentioned",
|
||||
want: []string{"first"},
|
||||
},
|
||||
{
|
||||
name: "mention at end",
|
||||
body: "mentioned at end @last",
|
||||
want: []string{"last"},
|
||||
},
|
||||
{
|
||||
name: "mixed duplicates and unique",
|
||||
body: "@alice @bob @alice @charlie @bob",
|
||||
want: []string{"alice", "bob", "charlie"},
|
||||
},
|
||||
{
|
||||
name: "email-like pattern",
|
||||
body: "contact user@example.com",
|
||||
want: []string{"example"},
|
||||
},
|
||||
{
|
||||
name: "alphanumeric agent name",
|
||||
body: "Hey @Agent42 check this",
|
||||
want: []string{"Agent42"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractMentions(tt.body)
|
||||
|
||||
if tt.want == nil {
|
||||
if got != nil {
|
||||
t.Errorf("ExtractMentions(%q) = %v, want nil", tt.body, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("ExtractMentions(%q) = %v (len %d), want %v (len %d)", tt.body, got, len(got), tt.want, len(tt.want))
|
||||
}
|
||||
|
||||
for i, g := range got {
|
||||
if g != tt.want[i] {
|
||||
t.Errorf("ExtractMentions(%q)[%d] = %q, want %q", tt.body, i, g, tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package dispatcher provides the event dispatch interface that decouples
|
||||
// message sending from delivery mechanisms (webhooks, K8s Jobs).
|
||||
package dispatcher
|
||||
@@ -0,0 +1,112 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
)
|
||||
|
||||
// K8sDispatcher implements dispatcher.EventDispatcher by launching K8s Jobs.
|
||||
type K8sDispatcher struct {
|
||||
store K8sStore
|
||||
runner JobRunner
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewK8sDispatcher creates a new K8s event dispatcher.
|
||||
func NewK8sDispatcher(store K8sStore, runner JobRunner, logger *slog.Logger) *K8sDispatcher {
|
||||
return &K8sDispatcher{
|
||||
store: store,
|
||||
runner: runner,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch creates K8s Jobs for all active handlers matching the event.
|
||||
func (d *K8sDispatcher) Dispatch(ctx context.Context, event dispatcher.MessageEvent) error {
|
||||
if !d.runner.IsAvailable() {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetAgent := event.ToAgent
|
||||
if targetAgent == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
handlers, err := d.store.GetActiveHandlersByEvent(ctx, targetAgent, event.EventType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get handlers for event: %w", err)
|
||||
}
|
||||
|
||||
for _, handler := range handlers {
|
||||
msg := &JobMessage{
|
||||
MessageID: event.MessageID,
|
||||
FromAgent: event.FromAgent,
|
||||
Body: event.Body,
|
||||
Event: event.EventType,
|
||||
Channel: event.Channel,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
jobName, err := d.runner.CreateJob(ctx, handler, msg)
|
||||
if err != nil {
|
||||
d.logger.Error("failed to create K8s Job",
|
||||
"handler_id", handler.ID,
|
||||
"agent", handler.AgentName,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
// Record the failed job run
|
||||
now := time.Now()
|
||||
run := &K8sJobRun{
|
||||
HandlerID: handler.ID,
|
||||
AgentName: handler.AgentName,
|
||||
MessageID: event.MessageID,
|
||||
JobName: fmt.Sprintf("failed-%d", event.MessageID),
|
||||
Namespace: handler.Namespace,
|
||||
Status: "failed",
|
||||
FailureReason: err.Error(),
|
||||
StartedAt: &now,
|
||||
CompletedAt: &now,
|
||||
}
|
||||
if _, insertErr := d.store.InsertJobRun(ctx, run); insertErr != nil {
|
||||
d.logger.Error("failed to record failed job run", "error", insertErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Record the job run as pending
|
||||
now := time.Now()
|
||||
namespace := handler.Namespace
|
||||
if namespace == "" {
|
||||
namespace = d.runner.GetNamespace()
|
||||
}
|
||||
run := &K8sJobRun{
|
||||
HandlerID: handler.ID,
|
||||
AgentName: handler.AgentName,
|
||||
MessageID: event.MessageID,
|
||||
JobName: jobName,
|
||||
Namespace: namespace,
|
||||
Status: "pending",
|
||||
StartedAt: &now,
|
||||
}
|
||||
if _, err := d.store.InsertJobRun(ctx, run); err != nil {
|
||||
d.logger.Error("failed to record job run",
|
||||
"job_name", jobName,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
d.logger.Info("K8s Job dispatched",
|
||||
"job_name", jobName,
|
||||
"handler_id", handler.ID,
|
||||
"agent", handler.AgentName,
|
||||
"event", event.EventType,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package k8s implements the Kubernetes Job runner for SynapBus.
|
||||
// When running in-cluster, it creates K8s Jobs in response to message events.
|
||||
// When not in-cluster, it provides a no-op implementation.
|
||||
package k8s
|
||||
@@ -0,0 +1,30 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NoopRunner is a no-op implementation of JobRunner for non-K8s environments.
|
||||
type NoopRunner struct{}
|
||||
|
||||
// NewNoopRunner creates a new no-op runner.
|
||||
func NewNoopRunner() *NoopRunner {
|
||||
return &NoopRunner{}
|
||||
}
|
||||
|
||||
func (r *NoopRunner) IsAvailable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *NoopRunner) GetNamespace() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *NoopRunner) CreateJob(ctx context.Context, handler *K8sHandler, msg *JobMessage) (string, error) {
|
||||
return "", fmt.Errorf("Kubernetes job runner is not available (not running in-cluster)")
|
||||
}
|
||||
|
||||
func (r *NoopRunner) GetJobLogs(ctx context.Context, namespace, jobName string) (string, error) {
|
||||
return "", fmt.Errorf("Kubernetes job runner is not available (not running in-cluster)")
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
// JobRunner is the interface for creating and managing K8s Jobs.
|
||||
type JobRunner interface {
|
||||
// IsAvailable returns true if the K8s runner is available (running in-cluster).
|
||||
IsAvailable() bool
|
||||
// CreateJob creates a K8s Job for the given handler and message.
|
||||
CreateJob(ctx context.Context, handler *K8sHandler, msg *JobMessage) (string, error)
|
||||
// GetJobLogs returns the logs for a completed Job.
|
||||
GetJobLogs(ctx context.Context, namespace, jobName string) (string, error)
|
||||
// GetNamespace returns the namespace SynapBus is running in.
|
||||
GetNamespace() string
|
||||
}
|
||||
|
||||
// JobMessage contains the message data to inject into the K8s Job.
|
||||
type JobMessage struct {
|
||||
MessageID int64
|
||||
FromAgent string
|
||||
Body string
|
||||
Event string
|
||||
Channel string
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
// K8sJobRunner implements JobRunner using the K8s API.
|
||||
type K8sJobRunner struct {
|
||||
clientset kubernetes.Interface
|
||||
namespace string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewJobRunner attempts to create a K8s runner using in-cluster config.
|
||||
// If not running in a K8s cluster, returns a NoopRunner.
|
||||
func NewJobRunner(logger *slog.Logger) JobRunner {
|
||||
config, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
logger.Info("not running in Kubernetes cluster, K8s job runner disabled", "reason", err.Error())
|
||||
return NewNoopRunner()
|
||||
}
|
||||
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
logger.Error("failed to create K8s client", "error", err)
|
||||
return NewNoopRunner()
|
||||
}
|
||||
|
||||
// Detect current namespace
|
||||
ns := detectNamespace()
|
||||
|
||||
logger.Info("Kubernetes job runner initialized", "namespace", ns)
|
||||
return &K8sJobRunner{
|
||||
clientset: clientset,
|
||||
namespace: ns,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// NewJobRunnerWithClient creates a K8s runner with a provided clientset (for testing).
|
||||
func NewJobRunnerWithClient(clientset kubernetes.Interface, namespace string, logger *slog.Logger) *K8sJobRunner {
|
||||
return &K8sJobRunner{
|
||||
clientset: clientset,
|
||||
namespace: namespace,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *K8sJobRunner) IsAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *K8sJobRunner) GetNamespace() string {
|
||||
return r.namespace
|
||||
}
|
||||
|
||||
func (r *K8sJobRunner) CreateJob(ctx context.Context, handler *K8sHandler, msg *JobMessage) (string, error) {
|
||||
jobName := sanitizeJobName(fmt.Sprintf("synapbus-%s-%d", handler.AgentName, msg.MessageID))
|
||||
|
||||
namespace := handler.Namespace
|
||||
if namespace == "" {
|
||||
namespace = r.namespace
|
||||
}
|
||||
|
||||
// Build environment variables
|
||||
envVars := []corev1.EnvVar{
|
||||
{Name: "SYNAPBUS_MESSAGE_ID", Value: fmt.Sprintf("%d", msg.MessageID)},
|
||||
{Name: "SYNAPBUS_MESSAGE_BODY", Value: truncateBody(msg.Body, 32768)},
|
||||
{Name: "SYNAPBUS_FROM_AGENT", Value: msg.FromAgent},
|
||||
{Name: "SYNAPBUS_EVENT", Value: msg.Event},
|
||||
{Name: "SYNAPBUS_TIMESTAMP", Value: msg.Timestamp},
|
||||
}
|
||||
if msg.Channel != "" {
|
||||
envVars = append(envVars, corev1.EnvVar{Name: "SYNAPBUS_CHANNEL", Value: msg.Channel})
|
||||
}
|
||||
|
||||
// Add user-defined env vars
|
||||
for k, v := range handler.Env {
|
||||
envVars = append(envVars, corev1.EnvVar{Name: k, Value: v})
|
||||
}
|
||||
|
||||
// Build resource limits
|
||||
resourceLimits := corev1.ResourceList{}
|
||||
if handler.ResourcesMemory != "" {
|
||||
resourceLimits[corev1.ResourceMemory] = resource.MustParse(handler.ResourcesMemory)
|
||||
}
|
||||
if handler.ResourcesCPU != "" {
|
||||
resourceLimits[corev1.ResourceCPU] = resource.MustParse(handler.ResourcesCPU)
|
||||
}
|
||||
|
||||
backoffLimit := int32(0)
|
||||
activeDeadline := int64(handler.TimeoutSeconds)
|
||||
ttlAfterFinished := int32(3600)
|
||||
|
||||
job := &batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: jobName,
|
||||
Namespace: namespace,
|
||||
Labels: map[string]string{
|
||||
"app.kubernetes.io/managed-by": "synapbus",
|
||||
"synapbus.io/agent": handler.AgentName,
|
||||
"synapbus.io/handler-id": fmt.Sprintf("%d", handler.ID),
|
||||
},
|
||||
},
|
||||
Spec: batchv1.JobSpec{
|
||||
BackoffLimit: &backoffLimit,
|
||||
ActiveDeadlineSeconds: &activeDeadline,
|
||||
TTLSecondsAfterFinished: &ttlAfterFinished,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "handler",
|
||||
Image: handler.Image,
|
||||
Env: envVars,
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: resourceLimits,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := r.clientset.BatchV1().Jobs(namespace).Create(ctx, job, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create K8s Job: %w", err)
|
||||
}
|
||||
|
||||
r.logger.Info("K8s Job created",
|
||||
"job_name", created.Name,
|
||||
"namespace", namespace,
|
||||
"agent", handler.AgentName,
|
||||
"image", handler.Image,
|
||||
)
|
||||
|
||||
return created.Name, nil
|
||||
}
|
||||
|
||||
func (r *K8sJobRunner) GetJobLogs(ctx context.Context, namespace, jobName string) (string, error) {
|
||||
// Find pods for this job
|
||||
pods, err := r.clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: fmt.Sprintf("job-name=%s", jobName),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list pods for job %s: %w", jobName, err)
|
||||
}
|
||||
|
||||
if len(pods.Items) == 0 {
|
||||
return "", fmt.Errorf("no pods found for job %s", jobName)
|
||||
}
|
||||
|
||||
// Get logs from the first pod
|
||||
pod := pods.Items[0]
|
||||
logStream, err := r.clientset.CoreV1().Pods(namespace).GetLogs(pod.Name, &corev1.PodLogOptions{}).Stream(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get logs for pod %s: %w", pod.Name, err)
|
||||
}
|
||||
defer logStream.Close()
|
||||
|
||||
logs, err := io.ReadAll(io.LimitReader(logStream, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read logs: %w", err)
|
||||
}
|
||||
|
||||
return string(logs), nil
|
||||
}
|
||||
|
||||
// detectNamespace reads the current namespace from the mounted service account.
|
||||
func detectNamespace() string {
|
||||
data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
|
||||
if err == nil && len(data) > 0 {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// sanitizeJobName ensures the job name is valid for Kubernetes (lowercase, max 63 chars, DNS-safe).
|
||||
func sanitizeJobName(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, name)
|
||||
// Trim leading/trailing hyphens
|
||||
name = strings.Trim(name, "-")
|
||||
if len(name) > 63 {
|
||||
name = name[:63]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// truncateBody truncates the message body to maxLen bytes.
|
||||
func truncateBody(body string, maxLen int) string {
|
||||
if len(body) <= maxLen {
|
||||
return body
|
||||
}
|
||||
return body[:maxLen]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeJobName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "simple lowercase",
|
||||
input: "synapbus-agent-123",
|
||||
want: "synapbus-agent-123",
|
||||
},
|
||||
{
|
||||
name: "uppercase converted to lowercase",
|
||||
input: "SynapBus-Agent-ABC",
|
||||
want: "synapbus-agent-abc",
|
||||
},
|
||||
{
|
||||
name: "special characters replaced with hyphens",
|
||||
input: "synapbus_agent.test@foo",
|
||||
want: "synapbus-agent-test-foo",
|
||||
},
|
||||
{
|
||||
name: "leading hyphens trimmed",
|
||||
input: "---leading",
|
||||
want: "leading",
|
||||
},
|
||||
{
|
||||
name: "trailing hyphens trimmed",
|
||||
input: "trailing---",
|
||||
want: "trailing",
|
||||
},
|
||||
{
|
||||
name: "max 63 characters",
|
||||
input: strings.Repeat("a", 100),
|
||||
want: strings.Repeat("a", 63),
|
||||
},
|
||||
{
|
||||
name: "long name with special chars truncated to 63",
|
||||
input: "synapbus-" + strings.Repeat("x", 100) + "-final",
|
||||
want: "synapbus-" + strings.Repeat("x", 54),
|
||||
},
|
||||
{
|
||||
name: "alphanumeric preserved",
|
||||
input: "abc123def456",
|
||||
want: "abc123def456",
|
||||
},
|
||||
{
|
||||
name: "spaces become hyphens",
|
||||
input: "my job name",
|
||||
want: "my-job-name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sanitizeJobName(tt.input)
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeJobName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
|
||||
// Verify invariants
|
||||
if len(got) > 63 {
|
||||
t.Errorf("result length %d exceeds 63", len(got))
|
||||
}
|
||||
if got != strings.ToLower(got) {
|
||||
t.Errorf("result %q is not all lowercase", got)
|
||||
}
|
||||
if strings.HasPrefix(got, "-") {
|
||||
t.Errorf("result %q starts with hyphen", got)
|
||||
}
|
||||
if strings.HasSuffix(got, "-") {
|
||||
t.Errorf("result %q ends with hyphen", got)
|
||||
}
|
||||
// Check DNS-safe: only a-z, 0-9, -
|
||||
for _, r := range got {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') {
|
||||
t.Errorf("result %q contains invalid rune %q", got, string(r))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "short body unchanged",
|
||||
body: "hello",
|
||||
maxLen: 100,
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "exact length unchanged",
|
||||
body: "hello",
|
||||
maxLen: 5,
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "long body truncated",
|
||||
body: "hello world this is a long message",
|
||||
maxLen: 11,
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: "",
|
||||
maxLen: 100,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "max zero truncates all",
|
||||
body: "hello",
|
||||
maxLen: 0,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := truncateBody(tt.body, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncateBody(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopRunner(t *testing.T) {
|
||||
runner := NewNoopRunner()
|
||||
|
||||
t.Run("IsAvailable returns false", func(t *testing.T) {
|
||||
if runner.IsAvailable() {
|
||||
t.Error("NoopRunner.IsAvailable() should return false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetNamespace returns empty string", func(t *testing.T) {
|
||||
if ns := runner.GetNamespace(); ns != "" {
|
||||
t.Errorf("NoopRunner.GetNamespace() = %q, want empty string", ns)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateJob returns error", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
handler := &K8sHandler{AgentName: "test", Image: "test:v1"}
|
||||
msg := &JobMessage{MessageID: 1, Body: "test"}
|
||||
_, err := runner.CreateJob(ctx, handler, msg)
|
||||
if err == nil {
|
||||
t.Error("NoopRunner.CreateJob() should return error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetJobLogs returns error", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, err := runner.GetJobLogs(ctx, "ns", "job-1")
|
||||
if err == nil {
|
||||
t.Error("NoopRunner.GetJobLogs() should return error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxHandlersPerAgent is the maximum number of K8s handlers per agent.
|
||||
MaxHandlersPerAgent = 3
|
||||
// HandlerStatusActive is the active handler status.
|
||||
HandlerStatusActive = "active"
|
||||
// HandlerStatusDisabled is the disabled handler status.
|
||||
HandlerStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
// ValidK8sEvents are the valid event types for K8s handlers.
|
||||
var ValidK8sEvents = []string{"message.received", "message.mentioned", "channel.message"}
|
||||
|
||||
// K8sService provides business logic for K8s handler operations.
|
||||
type K8sService struct {
|
||||
store K8sStore
|
||||
runner JobRunner
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewK8sService creates a new K8s handler service.
|
||||
func NewK8sService(store K8sStore, runner JobRunner) *K8sService {
|
||||
return &K8sService{
|
||||
store: store,
|
||||
runner: runner,
|
||||
logger: slog.Default().With("component", "k8s-service"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandler registers a new K8s handler for an agent.
|
||||
func (s *K8sService) RegisterHandler(ctx context.Context, agentName string, req RegisterHandlerRequest) (*K8sHandler, error) {
|
||||
// Validate events
|
||||
for _, e := range req.Events {
|
||||
if !isValidK8sEvent(e) {
|
||||
return nil, fmt.Errorf("invalid event type: %q", e)
|
||||
}
|
||||
}
|
||||
if len(req.Events) == 0 {
|
||||
return nil, fmt.Errorf("at least one event type is required")
|
||||
}
|
||||
|
||||
// Validate image
|
||||
if req.Image == "" {
|
||||
return nil, fmt.Errorf("image is required")
|
||||
}
|
||||
|
||||
// Check K8s availability
|
||||
if !s.runner.IsAvailable() {
|
||||
return nil, fmt.Errorf("Kubernetes runner is not available (not running in-cluster)")
|
||||
}
|
||||
|
||||
// Check max handlers
|
||||
count, err := s.store.CountHandlersByAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count handlers: %w", err)
|
||||
}
|
||||
if count >= MaxHandlersPerAgent {
|
||||
return nil, fmt.Errorf("maximum K8s handlers reached (%d/%d)", count, MaxHandlersPerAgent)
|
||||
}
|
||||
|
||||
// Default timeout
|
||||
timeout := req.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 300
|
||||
}
|
||||
|
||||
// Default namespace
|
||||
namespace := req.Namespace
|
||||
if namespace == "" {
|
||||
namespace = s.runner.GetNamespace()
|
||||
}
|
||||
|
||||
handler := &K8sHandler{
|
||||
AgentName: agentName,
|
||||
Image: req.Image,
|
||||
Events: req.Events,
|
||||
Namespace: namespace,
|
||||
ResourcesMemory: req.ResourcesMemory,
|
||||
ResourcesCPU: req.ResourcesCPU,
|
||||
Env: req.Env,
|
||||
TimeoutSeconds: timeout,
|
||||
Status: HandlerStatusActive,
|
||||
}
|
||||
|
||||
id, err := s.store.InsertHandler(ctx, handler)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert handler: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("K8s handler registered",
|
||||
"id", id,
|
||||
"agent", agentName,
|
||||
"image", req.Image,
|
||||
"events", req.Events,
|
||||
)
|
||||
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
// ListHandlers returns all K8s handlers for an agent.
|
||||
func (s *K8sService) ListHandlers(ctx context.Context, agentName string) ([]*K8sHandler, error) {
|
||||
return s.store.GetHandlersByAgent(ctx, agentName)
|
||||
}
|
||||
|
||||
// DeleteHandler deletes a K8s handler owned by the agent.
|
||||
func (s *K8sService) DeleteHandler(ctx context.Context, agentName string, handlerID int64) error {
|
||||
if err := s.store.DeleteHandler(ctx, handlerID, agentName); err != nil {
|
||||
return fmt.Errorf("delete handler: %w", err)
|
||||
}
|
||||
s.logger.Info("K8s handler deleted",
|
||||
"id", handlerID,
|
||||
"agent", agentName,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJobRuns returns job runs for an agent, optionally filtered by status.
|
||||
func (s *K8sService) GetJobRuns(ctx context.Context, agentName, status string, limit int) ([]*K8sJobRun, error) {
|
||||
return s.store.GetJobRunsByAgent(ctx, agentName, status, limit)
|
||||
}
|
||||
|
||||
// GetJobLogs returns the logs for a completed K8s Job.
|
||||
func (s *K8sService) GetJobLogs(ctx context.Context, namespace, jobName string) (string, error) {
|
||||
if !s.runner.IsAvailable() {
|
||||
return "", fmt.Errorf("Kubernetes runner is not available")
|
||||
}
|
||||
return s.runner.GetJobLogs(ctx, namespace, jobName)
|
||||
}
|
||||
|
||||
// IsAvailable returns whether the K8s runner is available.
|
||||
func (s *K8sService) IsAvailable() bool {
|
||||
return s.runner.IsAvailable()
|
||||
}
|
||||
|
||||
// Store returns the underlying K8s store.
|
||||
func (s *K8sService) Store() K8sStore {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// RegisterHandlerRequest contains the parameters for registering a K8s handler.
|
||||
type RegisterHandlerRequest struct {
|
||||
Image string `json:"image"`
|
||||
Events []string `json:"events"`
|
||||
Namespace string `json:"namespace"`
|
||||
ResourcesMemory string `json:"resources_memory"`
|
||||
ResourcesCPU string `json:"resources_cpu"`
|
||||
Env map[string]string `json:"env"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
|
||||
func isValidK8sEvent(event string) bool {
|
||||
for _, e := range ValidK8sEvents {
|
||||
if e == event {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeRunner implements JobRunner for testing, allowing control of IsAvailable
|
||||
// and namespace without requiring a real K8s cluster.
|
||||
type fakeRunner struct {
|
||||
available bool
|
||||
namespace string
|
||||
}
|
||||
|
||||
func (f *fakeRunner) IsAvailable() bool { return f.available }
|
||||
func (f *fakeRunner) GetNamespace() string { return f.namespace }
|
||||
func (f *fakeRunner) CreateJob(ctx context.Context, handler *K8sHandler, msg *JobMessage) (string, error) {
|
||||
return fmt.Sprintf("synapbus-%s-%d", handler.AgentName, msg.MessageID), nil
|
||||
}
|
||||
func (f *fakeRunner) GetJobLogs(ctx context.Context, namespace, jobName string) (string, error) {
|
||||
return "fake logs", nil
|
||||
}
|
||||
|
||||
func TestRegisterHandler_Success(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
runner := &fakeRunner{available: true, namespace: "default"}
|
||||
svc := NewK8sService(store, runner)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-k8s-1")
|
||||
|
||||
req := RegisterHandlerRequest{
|
||||
Image: "myapp:latest",
|
||||
Events: []string{"message.received"},
|
||||
Env: map[string]string{"FOO": "bar"},
|
||||
}
|
||||
|
||||
handler, err := svc.RegisterHandler(ctx, "svc-k8s-1", req)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterHandler() error = %v", err)
|
||||
}
|
||||
if handler.ID <= 0 {
|
||||
t.Errorf("expected positive ID, got %d", handler.ID)
|
||||
}
|
||||
if handler.AgentName != "svc-k8s-1" {
|
||||
t.Errorf("AgentName = %q, want %q", handler.AgentName, "svc-k8s-1")
|
||||
}
|
||||
if handler.Image != "myapp:latest" {
|
||||
t.Errorf("Image = %q, want %q", handler.Image, "myapp:latest")
|
||||
}
|
||||
if handler.Status != HandlerStatusActive {
|
||||
t.Errorf("Status = %q, want %q", handler.Status, HandlerStatusActive)
|
||||
}
|
||||
if handler.Namespace != "default" {
|
||||
t.Errorf("Namespace = %q, want %q (should default to runner namespace)", handler.Namespace, "default")
|
||||
}
|
||||
if handler.TimeoutSeconds != 300 {
|
||||
t.Errorf("TimeoutSeconds = %d, want 300 (default)", handler.TimeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHandler_CustomNamespaceAndTimeout(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
runner := &fakeRunner{available: true, namespace: "default"}
|
||||
svc := NewK8sService(store, runner)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-k8s-2")
|
||||
|
||||
req := RegisterHandlerRequest{
|
||||
Image: "myapp:latest",
|
||||
Events: []string{"message.received"},
|
||||
Namespace: "custom-ns",
|
||||
TimeoutSeconds: 600,
|
||||
}
|
||||
|
||||
handler, err := svc.RegisterHandler(ctx, "svc-k8s-2", req)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterHandler() error = %v", err)
|
||||
}
|
||||
if handler.Namespace != "custom-ns" {
|
||||
t.Errorf("Namespace = %q, want %q", handler.Namespace, "custom-ns")
|
||||
}
|
||||
if handler.TimeoutSeconds != 600 {
|
||||
t.Errorf("TimeoutSeconds = %d, want 600", handler.TimeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHandler_InvalidEvent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
runner := &fakeRunner{available: true, namespace: "default"}
|
||||
svc := NewK8sService(store, runner)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-k8s-3")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
events []string
|
||||
}{
|
||||
{name: "invalid event", events: []string{"bogus.event"}},
|
||||
{name: "empty events", events: []string{}},
|
||||
{name: "mix valid and invalid", events: []string{"message.received", "nope"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := RegisterHandlerRequest{
|
||||
Image: "myapp:latest",
|
||||
Events: tt.events,
|
||||
}
|
||||
_, err := svc.RegisterHandler(ctx, "svc-k8s-3", req)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid events, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHandler_MissingImage(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
runner := &fakeRunner{available: true, namespace: "default"}
|
||||
svc := NewK8sService(store, runner)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-k8s-4")
|
||||
|
||||
req := RegisterHandlerRequest{
|
||||
Image: "",
|
||||
Events: []string{"message.received"},
|
||||
}
|
||||
_, err := svc.RegisterHandler(ctx, "svc-k8s-4", req)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing image, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHandler_NotAvailable(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
runner := NewNoopRunner() // not available
|
||||
svc := NewK8sService(store, runner)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-k8s-5")
|
||||
|
||||
req := RegisterHandlerRequest{
|
||||
Image: "myapp:latest",
|
||||
Events: []string{"message.received"},
|
||||
}
|
||||
|
||||
_, err := svc.RegisterHandler(ctx, "svc-k8s-5", req)
|
||||
if err == nil {
|
||||
t.Error("expected error when K8s runner is not available, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHandler_MaxHandlers(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
runner := &fakeRunner{available: true, namespace: "default"}
|
||||
svc := NewK8sService(store, runner)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-k8s-6")
|
||||
|
||||
// Register MaxHandlersPerAgent handlers
|
||||
for i := 0; i < MaxHandlersPerAgent; i++ {
|
||||
req := RegisterHandlerRequest{
|
||||
Image: fmt.Sprintf("img-%d:v1", i),
|
||||
Events: []string{"message.received"},
|
||||
}
|
||||
_, err := svc.RegisterHandler(ctx, "svc-k8s-6", req)
|
||||
if err != nil {
|
||||
t.Fatalf("register handler %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Next one should fail
|
||||
req := RegisterHandlerRequest{
|
||||
Image: "one-too-many:v1",
|
||||
Events: []string{"message.received"},
|
||||
}
|
||||
_, err := svc.RegisterHandler(ctx, "svc-k8s-6", req)
|
||||
if err == nil {
|
||||
t.Error("expected error when exceeding max handlers, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteHandler_Service(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
runner := &fakeRunner{available: true, namespace: "default"}
|
||||
svc := NewK8sService(store, runner)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-k8s-7")
|
||||
seedAgent(t, db, "svc-k8s-8")
|
||||
|
||||
req := RegisterHandlerRequest{
|
||||
Image: "del-img:v1",
|
||||
Events: []string{"message.received"},
|
||||
}
|
||||
handler, err := svc.RegisterHandler(ctx, "svc-k8s-7", req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wrong owner
|
||||
err = svc.DeleteHandler(ctx, "svc-k8s-8", handler.ID)
|
||||
if err == nil {
|
||||
t.Error("expected error when deleting with wrong owner")
|
||||
}
|
||||
|
||||
// Correct owner
|
||||
err = svc.DeleteHandler(ctx, "svc-k8s-7", handler.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteHandler() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
handlers, err := svc.ListHandlers(ctx, "svc-k8s-7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(handlers) != 0 {
|
||||
t.Errorf("expected 0 handlers after delete, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHandlers(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
runner := &fakeRunner{available: true, namespace: "default"}
|
||||
svc := NewK8sService(store, runner)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-k8s-9")
|
||||
|
||||
// Empty initially
|
||||
handlers, err := svc.ListHandlers(ctx, "svc-k8s-9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(handlers) != 0 {
|
||||
t.Errorf("expected 0 handlers initially, got %d", len(handlers))
|
||||
}
|
||||
|
||||
// Register two
|
||||
for i := 0; i < 2; i++ {
|
||||
req := RegisterHandlerRequest{
|
||||
Image: fmt.Sprintf("list-img-%d:v1", i),
|
||||
Events: []string{"message.received"},
|
||||
}
|
||||
if _, err := svc.RegisterHandler(ctx, "svc-k8s-9", req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
handlers, err = svc.ListHandlers(ctx, "svc-k8s-9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(handlers) != 2 {
|
||||
t.Errorf("expected 2 handlers, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAvailable(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
|
||||
t.Run("available runner", func(t *testing.T) {
|
||||
svc := NewK8sService(store, &fakeRunner{available: true})
|
||||
if !svc.IsAvailable() {
|
||||
t.Error("expected IsAvailable() = true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unavailable runner", func(t *testing.T) {
|
||||
svc := NewK8sService(store, NewNoopRunner())
|
||||
if svc.IsAvailable() {
|
||||
t.Error("expected IsAvailable() = false")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// K8sHandler represents a registered Kubernetes job handler.
|
||||
type K8sHandler struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Image string `json:"image"`
|
||||
Events []string `json:"events"`
|
||||
Namespace string `json:"namespace"`
|
||||
ResourcesMemory string `json:"resources_memory"`
|
||||
ResourcesCPU string `json:"resources_cpu"`
|
||||
Env map[string]string `json:"env"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// K8sJobRun represents a single Kubernetes job execution.
|
||||
type K8sJobRun struct {
|
||||
ID int64 `json:"id"`
|
||||
HandlerID int64 `json:"handler_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
JobName string `json:"job_name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Status string `json:"status"`
|
||||
FailureReason string `json:"failure_reason,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// K8sStore defines the storage interface for K8s handler and job run operations.
|
||||
type K8sStore interface {
|
||||
InsertHandler(ctx context.Context, handler *K8sHandler) (int64, error)
|
||||
GetHandlerByID(ctx context.Context, id int64) (*K8sHandler, error)
|
||||
GetHandlersByAgent(ctx context.Context, agentName string) ([]*K8sHandler, error)
|
||||
GetActiveHandlersByEvent(ctx context.Context, agentName string, event string) ([]*K8sHandler, error)
|
||||
UpdateHandlerStatus(ctx context.Context, id int64, status string) error
|
||||
DeleteHandler(ctx context.Context, id int64, agentName string) error
|
||||
CountHandlersByAgent(ctx context.Context, agentName string) (int, error)
|
||||
InsertJobRun(ctx context.Context, run *K8sJobRun) (int64, error)
|
||||
UpdateJobRunStatus(ctx context.Context, id int64, status string, failureReason string, startedAt *time.Time, completedAt *time.Time) error
|
||||
GetJobRunsByHandler(ctx context.Context, handlerID int64, limit int) ([]*K8sJobRun, error)
|
||||
GetJobRunsByAgent(ctx context.Context, agentName string, status string, limit int) ([]*K8sJobRun, error)
|
||||
GetJobRunByID(ctx context.Context, id int64) (*K8sJobRun, error)
|
||||
GetJobRunByJobName(ctx context.Context, jobName string) (*K8sJobRun, error)
|
||||
}
|
||||
|
||||
// SQLiteK8sStore implements K8sStore using SQLite.
|
||||
type SQLiteK8sStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteK8sStore creates a new SQLite-backed K8s store.
|
||||
func NewSQLiteK8sStore(db *sql.DB) *SQLiteK8sStore {
|
||||
return &SQLiteK8sStore{db: db}
|
||||
}
|
||||
|
||||
// InsertHandler inserts a new K8s handler and sets the ID on the handler struct.
|
||||
func (s *SQLiteK8sStore) InsertHandler(ctx context.Context, handler *K8sHandler) (int64, error) {
|
||||
eventsJSON, err := json.Marshal(handler.Events)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal events: %w", err)
|
||||
}
|
||||
|
||||
envJSON, err := json.Marshal(handler.Env)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal env: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO k8s_handlers (agent_name, image, events, namespace, resources_memory, resources_cpu, env, timeout_seconds, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
handler.AgentName, handler.Image, string(eventsJSON), handler.Namespace,
|
||||
handler.ResourcesMemory, handler.ResourcesCPU, string(envJSON),
|
||||
handler.TimeoutSeconds, handler.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert k8s handler: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get handler id: %w", err)
|
||||
}
|
||||
handler.ID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetHandlerByID retrieves a single handler by its ID.
|
||||
func (s *SQLiteK8sStore) GetHandlerByID(ctx context.Context, id int64) (*K8sHandler, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, agent_name, image, events, namespace, resources_memory, resources_cpu, env, timeout_seconds, status, created_at, updated_at
|
||||
FROM k8s_handlers WHERE id = ?`, id,
|
||||
)
|
||||
return scanHandler(row)
|
||||
}
|
||||
|
||||
// GetHandlersByAgent retrieves all handlers for the given agent.
|
||||
func (s *SQLiteK8sStore) GetHandlersByAgent(ctx context.Context, agentName string) ([]*K8sHandler, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, agent_name, image, events, namespace, resources_memory, resources_cpu, env, timeout_seconds, status, created_at, updated_at
|
||||
FROM k8s_handlers WHERE agent_name = ?
|
||||
ORDER BY created_at DESC`, agentName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get handlers by agent: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanHandlers(rows)
|
||||
}
|
||||
|
||||
// GetActiveHandlersByEvent retrieves active handlers for the given agent that listen to the specified event.
|
||||
func (s *SQLiteK8sStore) GetActiveHandlersByEvent(ctx context.Context, agentName string, event string) ([]*K8sHandler, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, agent_name, image, events, namespace, resources_memory, resources_cpu, env, timeout_seconds, status, created_at, updated_at
|
||||
FROM k8s_handlers WHERE agent_name = ? AND status = 'active'
|
||||
ORDER BY created_at DESC`, agentName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active handlers by event: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var handlers []*K8sHandler
|
||||
for rows.Next() {
|
||||
h, err := scanHandlerFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range h.Events {
|
||||
if e == event {
|
||||
handlers = append(handlers, h)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate active handlers: %w", err)
|
||||
}
|
||||
if handlers == nil {
|
||||
handlers = []*K8sHandler{}
|
||||
}
|
||||
return handlers, nil
|
||||
}
|
||||
|
||||
// UpdateHandlerStatus updates the status of a handler.
|
||||
func (s *SQLiteK8sStore) UpdateHandlerStatus(ctx context.Context, id int64, status string) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE k8s_handlers SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
status, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update handler status: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("handler not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteHandler deletes a handler only if it is owned by the specified agent.
|
||||
func (s *SQLiteK8sStore) DeleteHandler(ctx context.Context, id int64, agentName string) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM k8s_handlers WHERE id = ? AND agent_name = ?`,
|
||||
id, agentName,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete handler: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("handler not found or not owned by agent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountHandlersByAgent returns the number of handlers for the given agent.
|
||||
func (s *SQLiteK8sStore) CountHandlersByAgent(ctx context.Context, agentName string) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM k8s_handlers WHERE agent_name = ?`, agentName,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count handlers by agent: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// InsertJobRun inserts a new job run record and sets the ID on the run struct.
|
||||
func (s *SQLiteK8sStore) InsertJobRun(ctx context.Context, run *K8sJobRun) (int64, error) {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO k8s_job_runs (handler_id, agent_name, message_id, job_name, namespace, status, failure_reason, started_at, completed_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
run.HandlerID, run.AgentName, run.MessageID, run.JobName, run.Namespace,
|
||||
run.Status, run.FailureReason, run.StartedAt, run.CompletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert job run: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get job run id: %w", err)
|
||||
}
|
||||
run.ID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateJobRunStatus updates the status and related fields of a job run.
|
||||
func (s *SQLiteK8sStore) UpdateJobRunStatus(ctx context.Context, id int64, status string, failureReason string, startedAt *time.Time, completedAt *time.Time) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE k8s_job_runs SET status = ?, failure_reason = ?, started_at = ?, completed_at = ? WHERE id = ?`,
|
||||
status, failureReason, startedAt, completedAt, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update job run status: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("job run not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJobRunsByHandler retrieves job runs for a specific handler, ordered by most recent first.
|
||||
func (s *SQLiteK8sStore) GetJobRunsByHandler(ctx context.Context, handlerID int64, limit int) ([]*K8sJobRun, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, handler_id, agent_name, message_id, job_name, namespace, status, failure_reason, started_at, completed_at, created_at
|
||||
FROM k8s_job_runs WHERE handler_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`, handlerID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get job runs by handler: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanJobRuns(rows)
|
||||
}
|
||||
|
||||
// GetJobRunsByAgent retrieves job runs for an agent, optionally filtered by status.
|
||||
func (s *SQLiteK8sStore) GetJobRunsByAgent(ctx context.Context, agentName string, status string, limit int) ([]*K8sJobRun, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
|
||||
if status != "" {
|
||||
query = `SELECT id, handler_id, agent_name, message_id, job_name, namespace, status, failure_reason, started_at, completed_at, created_at
|
||||
FROM k8s_job_runs WHERE agent_name = ? AND status = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
args = []any{agentName, status, limit}
|
||||
} else {
|
||||
query = `SELECT id, handler_id, agent_name, message_id, job_name, namespace, status, failure_reason, started_at, completed_at, created_at
|
||||
FROM k8s_job_runs WHERE agent_name = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
args = []any{agentName, limit}
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get job runs by agent: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanJobRuns(rows)
|
||||
}
|
||||
|
||||
// GetJobRunByID retrieves a single job run by its database ID.
|
||||
func (s *SQLiteK8sStore) GetJobRunByID(ctx context.Context, id int64) (*K8sJobRun, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, handler_id, agent_name, message_id, job_name, namespace, status, failure_reason, started_at, completed_at, created_at
|
||||
FROM k8s_job_runs WHERE id = ?`, id,
|
||||
)
|
||||
return scanJobRun(row)
|
||||
}
|
||||
|
||||
// GetJobRunByJobName retrieves a single job run by its Kubernetes job name.
|
||||
func (s *SQLiteK8sStore) GetJobRunByJobName(ctx context.Context, jobName string) (*K8sJobRun, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, handler_id, agent_name, message_id, job_name, namespace, status, failure_reason, started_at, completed_at, created_at
|
||||
FROM k8s_job_runs WHERE job_name = ?`, jobName,
|
||||
)
|
||||
return scanJobRun(row)
|
||||
}
|
||||
|
||||
// scanHandler scans a single handler from sql.Row.
|
||||
func scanHandler(row *sql.Row) (*K8sHandler, error) {
|
||||
var h K8sHandler
|
||||
var eventsJSON, envJSON string
|
||||
|
||||
err := row.Scan(
|
||||
&h.ID, &h.AgentName, &h.Image, &eventsJSON, &h.Namespace,
|
||||
&h.ResourcesMemory, &h.ResourcesCPU, &envJSON, &h.TimeoutSeconds,
|
||||
&h.Status, &h.CreatedAt, &h.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(eventsJSON), &h.Events); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal events: %w", err)
|
||||
}
|
||||
if h.Events == nil {
|
||||
h.Events = []string{}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(envJSON), &h.Env); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal env: %w", err)
|
||||
}
|
||||
if h.Env == nil {
|
||||
h.Env = map[string]string{}
|
||||
}
|
||||
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
// scanHandlerFromRows scans a single handler from sql.Rows.
|
||||
func scanHandlerFromRows(rows *sql.Rows) (*K8sHandler, error) {
|
||||
var h K8sHandler
|
||||
var eventsJSON, envJSON string
|
||||
|
||||
err := rows.Scan(
|
||||
&h.ID, &h.AgentName, &h.Image, &eventsJSON, &h.Namespace,
|
||||
&h.ResourcesMemory, &h.ResourcesCPU, &envJSON, &h.TimeoutSeconds,
|
||||
&h.Status, &h.CreatedAt, &h.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan handler: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(eventsJSON), &h.Events); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal events: %w", err)
|
||||
}
|
||||
if h.Events == nil {
|
||||
h.Events = []string{}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(envJSON), &h.Env); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal env: %w", err)
|
||||
}
|
||||
if h.Env == nil {
|
||||
h.Env = map[string]string{}
|
||||
}
|
||||
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
// scanHandlers scans multiple handler rows.
|
||||
func scanHandlers(rows *sql.Rows) ([]*K8sHandler, error) {
|
||||
var handlers []*K8sHandler
|
||||
for rows.Next() {
|
||||
h, err := scanHandlerFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handlers = append(handlers, h)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate handlers: %w", err)
|
||||
}
|
||||
if handlers == nil {
|
||||
handlers = []*K8sHandler{}
|
||||
}
|
||||
return handlers, nil
|
||||
}
|
||||
|
||||
// scanJobRun scans a single job run from sql.Row.
|
||||
func scanJobRun(row *sql.Row) (*K8sJobRun, error) {
|
||||
var run K8sJobRun
|
||||
var failureReason sql.NullString
|
||||
var startedAt, completedAt sql.NullTime
|
||||
|
||||
err := row.Scan(
|
||||
&run.ID, &run.HandlerID, &run.AgentName, &run.MessageID, &run.JobName,
|
||||
&run.Namespace, &run.Status, &failureReason, &startedAt, &completedAt,
|
||||
&run.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if failureReason.Valid {
|
||||
run.FailureReason = failureReason.String
|
||||
}
|
||||
if startedAt.Valid {
|
||||
run.StartedAt = &startedAt.Time
|
||||
}
|
||||
if completedAt.Valid {
|
||||
run.CompletedAt = &completedAt.Time
|
||||
}
|
||||
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
// scanJobRunFromRows scans a single job run from sql.Rows.
|
||||
func scanJobRunFromRows(rows *sql.Rows) (*K8sJobRun, error) {
|
||||
var run K8sJobRun
|
||||
var failureReason sql.NullString
|
||||
var startedAt, completedAt sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
&run.ID, &run.HandlerID, &run.AgentName, &run.MessageID, &run.JobName,
|
||||
&run.Namespace, &run.Status, &failureReason, &startedAt, &completedAt,
|
||||
&run.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan job run: %w", err)
|
||||
}
|
||||
|
||||
if failureReason.Valid {
|
||||
run.FailureReason = failureReason.String
|
||||
}
|
||||
if startedAt.Valid {
|
||||
run.StartedAt = &startedAt.Time
|
||||
}
|
||||
if completedAt.Valid {
|
||||
run.CompletedAt = &completedAt.Time
|
||||
}
|
||||
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
// scanJobRuns scans multiple job run rows.
|
||||
func scanJobRuns(rows *sql.Rows) ([]*K8sJobRun, error) {
|
||||
var runs []*K8sJobRun
|
||||
for rows.Next() {
|
||||
run, err := scanJobRunFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runs = append(runs, run)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate job runs: %w", err)
|
||||
}
|
||||
if runs == nil {
|
||||
runs = []*K8sJobRun{}
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := storage.RunMigrations(context.Background(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// seedAgent inserts prerequisite user and agent rows so that foreign key
|
||||
// constraints on k8s_handlers (agent_name -> agents.name) are satisfied.
|
||||
func seedAgent(t *testing.T, db *sql.DB, agentName string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO users (username, password_hash, display_name) VALUES (?, 'hash', 'Test User')`,
|
||||
"owner-"+agentName,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
var ownerID int64
|
||||
err = db.QueryRowContext(ctx, `SELECT id FROM users WHERE username = ?`, "owner-"+agentName).Scan(&ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("get owner id: %v", err)
|
||||
}
|
||||
_, err = db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', ?, 'hash', 'active')`,
|
||||
agentName, agentName, ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestHandler(agentName, image, namespace string, events []string) *K8sHandler {
|
||||
return &K8sHandler{
|
||||
AgentName: agentName,
|
||||
Image: image,
|
||||
Events: events,
|
||||
Namespace: namespace,
|
||||
ResourcesMemory: "256Mi",
|
||||
ResourcesCPU: "100m",
|
||||
Env: map[string]string{"KEY": "value"},
|
||||
TimeoutSeconds: 300,
|
||||
Status: HandlerStatusActive,
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertHandler(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "k8s-agent-a")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
handler *K8sHandler
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid handler",
|
||||
handler: makeTestHandler("k8s-agent-a", "myimage:latest", "default", []string{"message.received"}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "different image same agent",
|
||||
handler: makeTestHandler("k8s-agent-a", "other:v2", "default", []string{"message.mentioned"}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate agent+image+namespace fails",
|
||||
handler: makeTestHandler("k8s-agent-a", "myimage:latest", "default", []string{"message.received"}),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id, err := store.InsertHandler(ctx, tt.handler)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("InsertHandler() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && id <= 0 {
|
||||
t.Errorf("expected positive ID, got %d", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHandlerByID(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "k8s-agent-b")
|
||||
|
||||
h := makeTestHandler("k8s-agent-b", "myimage:latest", "test-ns", []string{"message.received", "channel.message"})
|
||||
id, err := store.InsertHandler(ctx, h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id int64
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "existing handler", id: id, wantErr: false},
|
||||
{name: "non-existent handler", id: 9999, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := store.GetHandlerByID(ctx, tt.id)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("GetHandlerByID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if got.ID != id {
|
||||
t.Errorf("ID = %d, want %d", got.ID, id)
|
||||
}
|
||||
if got.AgentName != "k8s-agent-b" {
|
||||
t.Errorf("AgentName = %q, want %q", got.AgentName, "k8s-agent-b")
|
||||
}
|
||||
if got.Image != "myimage:latest" {
|
||||
t.Errorf("Image = %q, want %q", got.Image, "myimage:latest")
|
||||
}
|
||||
if got.Namespace != "test-ns" {
|
||||
t.Errorf("Namespace = %q, want %q", got.Namespace, "test-ns")
|
||||
}
|
||||
if len(got.Events) != 2 {
|
||||
t.Errorf("Events length = %d, want 2", len(got.Events))
|
||||
}
|
||||
if got.Env["KEY"] != "value" {
|
||||
t.Errorf("Env[KEY] = %q, want %q", got.Env["KEY"], "value")
|
||||
}
|
||||
if got.TimeoutSeconds != 300 {
|
||||
t.Errorf("TimeoutSeconds = %d, want 300", got.TimeoutSeconds)
|
||||
}
|
||||
if got.Status != HandlerStatusActive {
|
||||
t.Errorf("Status = %q, want %q", got.Status, HandlerStatusActive)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHandlersByAgent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "k8s-agent-c")
|
||||
seedAgent(t, db, "k8s-agent-d")
|
||||
|
||||
// Two handlers for agent-c
|
||||
h1 := makeTestHandler("k8s-agent-c", "img1:v1", "ns1", []string{"message.received"})
|
||||
if _, err := store.InsertHandler(ctx, h1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h2 := makeTestHandler("k8s-agent-c", "img2:v1", "ns1", []string{"message.mentioned"})
|
||||
if _, err := store.InsertHandler(ctx, h2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// One handler for agent-d
|
||||
h3 := makeTestHandler("k8s-agent-d", "img1:v1", "ns1", []string{"message.received"})
|
||||
if _, err := store.InsertHandler(ctx, h3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentName string
|
||||
wantCount int
|
||||
}{
|
||||
{name: "agent with two handlers", agentName: "k8s-agent-c", wantCount: 2},
|
||||
{name: "agent with one handler", agentName: "k8s-agent-d", wantCount: 1},
|
||||
{name: "non-existent agent", agentName: "nope", wantCount: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handlers, err := store.GetHandlersByAgent(ctx, tt.agentName)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHandlersByAgent() error = %v", err)
|
||||
}
|
||||
if len(handlers) != tt.wantCount {
|
||||
t.Errorf("got %d handlers, want %d", len(handlers), tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveHandlersByEvent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "k8s-agent-e")
|
||||
|
||||
// Active handler for message.received
|
||||
h1 := makeTestHandler("k8s-agent-e", "img-recv:v1", "ns", []string{"message.received"})
|
||||
if _, err := store.InsertHandler(ctx, h1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Active handler for both events
|
||||
h2 := makeTestHandler("k8s-agent-e", "img-both:v1", "ns", []string{"message.received", "message.mentioned"})
|
||||
if _, err := store.InsertHandler(ctx, h2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Disabled handler for message.received (should not appear)
|
||||
h3 := makeTestHandler("k8s-agent-e", "img-dis:v1", "ns", []string{"message.received"})
|
||||
h3.Status = HandlerStatusDisabled
|
||||
if _, err := store.InsertHandler(ctx, h3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentName string
|
||||
event string
|
||||
wantCount int
|
||||
}{
|
||||
{name: "message.received matches two active", agentName: "k8s-agent-e", event: "message.received", wantCount: 2},
|
||||
{name: "message.mentioned matches one active", agentName: "k8s-agent-e", event: "message.mentioned", wantCount: 1},
|
||||
{name: "channel.message matches none", agentName: "k8s-agent-e", event: "channel.message", wantCount: 0},
|
||||
{name: "non-existent agent", agentName: "nope", event: "message.received", wantCount: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handlers, err := store.GetActiveHandlersByEvent(ctx, tt.agentName, tt.event)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveHandlersByEvent() error = %v", err)
|
||||
}
|
||||
if len(handlers) != tt.wantCount {
|
||||
t.Errorf("got %d handlers, want %d", len(handlers), tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteHandler(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "k8s-agent-f")
|
||||
seedAgent(t, db, "k8s-agent-g")
|
||||
|
||||
h := makeTestHandler("k8s-agent-f", "del-img:v1", "ns", []string{"message.received"})
|
||||
id, err := store.InsertHandler(ctx, h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id int64
|
||||
agentName string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "wrong owner fails", id: id, agentName: "k8s-agent-g", wantErr: true},
|
||||
{name: "correct owner succeeds", id: id, agentName: "k8s-agent-f", wantErr: false},
|
||||
{name: "already deleted fails", id: id, agentName: "k8s-agent-f", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := store.DeleteHandler(ctx, tt.id, tt.agentName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("DeleteHandler() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertJobRun(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "k8s-agent-h")
|
||||
|
||||
h := makeTestHandler("k8s-agent-h", "job-img:v1", "ns", []string{"message.received"})
|
||||
hID, err := store.InsertHandler(ctx, h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
run := &K8sJobRun{
|
||||
HandlerID: hID,
|
||||
AgentName: "k8s-agent-h",
|
||||
MessageID: 100,
|
||||
JobName: "synapbus-k8s-agent-h-100",
|
||||
Namespace: "ns",
|
||||
Status: "pending",
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
id, err := store.InsertJobRun(ctx, run)
|
||||
if err != nil {
|
||||
t.Fatalf("InsertJobRun() error = %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Errorf("expected positive ID, got %d", id)
|
||||
}
|
||||
if run.ID != id {
|
||||
t.Errorf("run.ID = %d, want %d", run.ID, id)
|
||||
}
|
||||
|
||||
// Verify round trip
|
||||
got, err := store.GetJobRunByJobName(ctx, "synapbus-k8s-agent-h-100")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.HandlerID != hID {
|
||||
t.Errorf("HandlerID = %d, want %d", got.HandlerID, hID)
|
||||
}
|
||||
if got.Status != "pending" {
|
||||
t.Errorf("Status = %q, want %q", got.Status, "pending")
|
||||
}
|
||||
if got.StartedAt == nil {
|
||||
t.Error("StartedAt should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobRunStatus(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "k8s-agent-i")
|
||||
|
||||
h := makeTestHandler("k8s-agent-i", "upd-img:v1", "ns", []string{"message.received"})
|
||||
hID, err := store.InsertHandler(ctx, h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
run := &K8sJobRun{
|
||||
HandlerID: hID,
|
||||
AgentName: "k8s-agent-i",
|
||||
MessageID: 200,
|
||||
JobName: "synapbus-k8s-agent-i-200",
|
||||
Namespace: "ns",
|
||||
Status: "pending",
|
||||
StartedAt: &now,
|
||||
}
|
||||
id, err := store.InsertJobRun(ctx, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Update to succeeded
|
||||
completed := time.Now()
|
||||
err = store.UpdateJobRunStatus(ctx, id, "succeeded", "", &now, &completed)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateJobRunStatus(succeeded) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetJobRunByJobName(ctx, "synapbus-k8s-agent-i-200")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "succeeded" {
|
||||
t.Errorf("Status = %q, want %q", got.Status, "succeeded")
|
||||
}
|
||||
if got.CompletedAt == nil {
|
||||
t.Error("CompletedAt should not be nil")
|
||||
}
|
||||
|
||||
// Update to failed
|
||||
err = store.UpdateJobRunStatus(ctx, id, "failed", "OOMKilled", &now, &completed)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateJobRunStatus(failed) error = %v", err)
|
||||
}
|
||||
|
||||
got, err = store.GetJobRunByJobName(ctx, "synapbus-k8s-agent-i-200")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "failed" {
|
||||
t.Errorf("Status = %q, want %q", got.Status, "failed")
|
||||
}
|
||||
if got.FailureReason != "OOMKilled" {
|
||||
t.Errorf("FailureReason = %q, want %q", got.FailureReason, "OOMKilled")
|
||||
}
|
||||
|
||||
// Non-existent run
|
||||
err = store.UpdateJobRunStatus(ctx, 9999, "failed", "test", nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent job run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobRunsByAgent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteK8sStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "k8s-agent-j")
|
||||
seedAgent(t, db, "k8s-agent-k")
|
||||
|
||||
hJ := makeTestHandler("k8s-agent-j", "runs-img:v1", "ns", []string{"message.received"})
|
||||
hJID, err := store.InsertHandler(ctx, hJ)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hK := makeTestHandler("k8s-agent-k", "runs-img:v1", "ns", []string{"message.received"})
|
||||
hKID, err := store.InsertHandler(ctx, hK)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Runs for agent-j
|
||||
for i, status := range []string{"pending", "succeeded", "failed"} {
|
||||
run := &K8sJobRun{
|
||||
HandlerID: hJID,
|
||||
AgentName: "k8s-agent-j",
|
||||
MessageID: int64(300 + i),
|
||||
JobName: "j-run-" + status,
|
||||
Namespace: "ns",
|
||||
Status: status,
|
||||
}
|
||||
if _, err := store.InsertJobRun(ctx, run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run for agent-k
|
||||
runK := &K8sJobRun{
|
||||
HandlerID: hKID,
|
||||
AgentName: "k8s-agent-k",
|
||||
MessageID: 400,
|
||||
JobName: "k-run-1",
|
||||
Namespace: "ns",
|
||||
Status: "pending",
|
||||
}
|
||||
if _, err := store.InsertJobRun(ctx, runK); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentName string
|
||||
status string
|
||||
wantCount int
|
||||
}{
|
||||
{name: "all runs for agent-j", agentName: "k8s-agent-j", status: "", wantCount: 3},
|
||||
{name: "pending for agent-j", agentName: "k8s-agent-j", status: "pending", wantCount: 1},
|
||||
{name: "succeeded for agent-j", agentName: "k8s-agent-j", status: "succeeded", wantCount: 1},
|
||||
{name: "failed for agent-j", agentName: "k8s-agent-j", status: "failed", wantCount: 1},
|
||||
{name: "all for agent-k", agentName: "k8s-agent-k", status: "", wantCount: 1},
|
||||
{name: "non-existent agent", agentName: "nope", status: "", wantCount: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runs, err := store.GetJobRunsByAgent(ctx, tt.agentName, tt.status, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJobRunsByAgent() error = %v", err)
|
||||
}
|
||||
if len(runs) != tt.wantCount {
|
||||
t.Errorf("got %d runs, want %d", len(runs), tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/attachments"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/console"
|
||||
"github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/search"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
)
|
||||
|
||||
// MCPServer wraps the mcp-go server with SynapBus services.
|
||||
@@ -38,6 +40,8 @@ func NewMCPServer(
|
||||
attachmentService *attachments.Service,
|
||||
searchService *search.Service,
|
||||
consolePrinter *console.Printer,
|
||||
webhookService *webhooks.WebhookService,
|
||||
k8sService *k8s.K8sService,
|
||||
) *MCPServer {
|
||||
logger := slog.Default().With("component", "mcp-server")
|
||||
connMgr := NewConnectionManager()
|
||||
@@ -162,6 +166,12 @@ func NewMCPServer(
|
||||
attachmentRegistrar.RegisterAll(mcpSrv)
|
||||
}
|
||||
|
||||
// Register webhook and K8s handler tools
|
||||
if webhookService != nil || k8sService != nil {
|
||||
webhookRegistrar := NewWebhookToolRegistrar(webhookService, k8sService)
|
||||
webhookRegistrar.RegisterAll(mcpSrv)
|
||||
}
|
||||
|
||||
// Create Streamable HTTP transport with context func for auth propagation
|
||||
httpServer := server.NewStreamableHTTPServer(mcpSrv,
|
||||
server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestNewMCPServerWithConsole(t *testing.T) {
|
||||
|
||||
con := console.New()
|
||||
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, con)
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, con, nil, nil)
|
||||
if srv == nil {
|
||||
t.Fatal("expected non-nil MCPServer")
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestNewMCPServerNilConsole(t *testing.T) {
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
// nil console should not panic
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil)
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil)
|
||||
if srv == nil {
|
||||
t.Fatal("expected non-nil MCPServer")
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func TestMCPToolCall_WithValidAPIKey(t *testing.T) {
|
||||
agentService.Register(ctx, "receiver", "Receiver", "ai", nil, 1)
|
||||
|
||||
// Create MCP server
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil)
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// Mount with auth middleware, just like main.go does
|
||||
mux := http.NewServeMux()
|
||||
@@ -180,7 +180,7 @@ func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) {
|
||||
apiKeyStore := apikeys.NewSQLiteStore(db)
|
||||
apiKeyService := apikeys.NewService(apiKeyStore)
|
||||
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil)
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handler := agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService)(srv.Handler())
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
)
|
||||
|
||||
// WebhookToolRegistrar registers webhook and K8s handler MCP tools.
|
||||
type WebhookToolRegistrar struct {
|
||||
webhookService *webhooks.WebhookService
|
||||
k8sService *k8s.K8sService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWebhookToolRegistrar creates a new webhook tool registrar.
|
||||
func NewWebhookToolRegistrar(webhookService *webhooks.WebhookService, k8sService *k8s.K8sService) *WebhookToolRegistrar {
|
||||
return &WebhookToolRegistrar{
|
||||
webhookService: webhookService,
|
||||
k8sService: k8sService,
|
||||
logger: slog.Default().With("component", "mcp-webhook-tools"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAll registers all webhook and K8s handler tools on the MCP server.
|
||||
func (r *WebhookToolRegistrar) RegisterAll(s *server.MCPServer) {
|
||||
count := 0
|
||||
|
||||
// Webhook tools
|
||||
if r.webhookService != nil {
|
||||
s.AddTool(r.registerWebhookTool(), r.handleRegisterWebhook)
|
||||
s.AddTool(r.listWebhooksTool(), r.handleListWebhooks)
|
||||
s.AddTool(r.deleteWebhookTool(), r.handleDeleteWebhook)
|
||||
count += 3
|
||||
}
|
||||
|
||||
// K8s handler tools
|
||||
if r.k8sService != nil {
|
||||
s.AddTool(r.registerK8sHandlerTool(), r.handleRegisterK8sHandler)
|
||||
s.AddTool(r.listK8sHandlersTool(), r.handleListK8sHandlers)
|
||||
s.AddTool(r.deleteK8sHandlerTool(), r.handleDeleteK8sHandler)
|
||||
count += 3
|
||||
}
|
||||
|
||||
r.logger.Info("webhook/K8s MCP tools registered", "count", count)
|
||||
}
|
||||
|
||||
// --- Webhook Tool Definitions ---
|
||||
|
||||
func (r *WebhookToolRegistrar) registerWebhookTool() mcp.Tool {
|
||||
return mcp.NewTool("register_webhook",
|
||||
mcp.WithDescription("Register a webhook URL to receive event notifications. When matching events occur (messages, mentions), SynapBus will POST a signed JSON payload to your URL. Max 3 webhooks per agent. HTTPS required in production."),
|
||||
mcp.WithString("url", mcp.Description("HTTPS URL to receive webhook POST requests"), mcp.Required()),
|
||||
mcp.WithString("events", mcp.Description("Comma-separated event types: message.received, message.mentioned, channel.message"), mcp.Required()),
|
||||
mcp.WithString("secret", mcp.Description("Shared secret for HMAC-SHA256 payload signing (X-SynapBus-Signature header)"), mcp.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *WebhookToolRegistrar) listWebhooksTool() mcp.Tool {
|
||||
return mcp.NewTool("list_webhooks",
|
||||
mcp.WithDescription("List your registered webhooks and their status (active/disabled, failure counts)."),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *WebhookToolRegistrar) deleteWebhookTool() mcp.Tool {
|
||||
return mcp.NewTool("delete_webhook",
|
||||
mcp.WithDescription("Delete one of your registered webhooks by ID."),
|
||||
mcp.WithNumber("webhook_id", mcp.Description("ID of the webhook to delete"), mcp.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Webhook Tool Handlers ---
|
||||
|
||||
func (r *WebhookToolRegistrar) handleRegisterWebhook(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
url := req.GetString("url", "")
|
||||
eventsStr := req.GetString("events", "")
|
||||
secret := req.GetString("secret", "")
|
||||
|
||||
if url == "" {
|
||||
return mcp.NewToolResultError("'url' parameter is required"), nil
|
||||
}
|
||||
if eventsStr == "" {
|
||||
return mcp.NewToolResultError("'events' parameter is required"), nil
|
||||
}
|
||||
if secret == "" {
|
||||
return mcp.NewToolResultError("'secret' parameter is required"), nil
|
||||
}
|
||||
|
||||
// Parse comma-separated events
|
||||
events := parseEvents(eventsStr)
|
||||
|
||||
wh, err := r.webhookService.RegisterWebhook(ctx, agentName, url, events, secret)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("register_webhook failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"webhook_id": wh.ID,
|
||||
"url": wh.URL,
|
||||
"events": wh.Events,
|
||||
"status": wh.Status,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *WebhookToolRegistrar) handleListWebhooks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
hooks, err := r.webhookService.ListWebhooks(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("list_webhooks failed: %s", err)), nil
|
||||
}
|
||||
|
||||
result := make([]map[string]any, len(hooks))
|
||||
for i, wh := range hooks {
|
||||
result[i] = map[string]any{
|
||||
"id": wh.ID,
|
||||
"url": wh.URL,
|
||||
"events": wh.Events,
|
||||
"status": wh.Status,
|
||||
"consecutive_failures": wh.ConsecutiveFailures,
|
||||
"created_at": wh.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"webhooks": result,
|
||||
"count": len(result),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *WebhookToolRegistrar) handleDeleteWebhook(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
webhookID, err := req.RequireInt("webhook_id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("'webhook_id' parameter is required"), nil
|
||||
}
|
||||
|
||||
if err := r.webhookService.DeleteWebhook(ctx, agentName, int64(webhookID)); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("delete_webhook failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"deleted": true,
|
||||
"webhook_id": webhookID,
|
||||
})
|
||||
}
|
||||
|
||||
// --- K8s Handler Tool Definitions ---
|
||||
|
||||
func (r *WebhookToolRegistrar) registerK8sHandlerTool() mcp.Tool {
|
||||
return mcp.NewTool("register_k8s_handler",
|
||||
mcp.WithDescription("Register a Kubernetes Job handler. When matching events occur, SynapBus launches a K8s Job with message data injected via environment variables. Only available when SynapBus runs in-cluster."),
|
||||
mcp.WithString("image", mcp.Description("Container image to run (e.g. myregistry/handler:v1)"), mcp.Required()),
|
||||
mcp.WithString("events", mcp.Description("Comma-separated event types: message.received, message.mentioned, channel.message"), mcp.Required()),
|
||||
mcp.WithString("namespace", mcp.Description("Kubernetes namespace (default: SynapBus's namespace)")),
|
||||
mcp.WithString("resources_memory", mcp.Description("Memory limit (e.g. 256Mi, 1Gi)")),
|
||||
mcp.WithString("resources_cpu", mcp.Description("CPU limit (e.g. 100m, 1)")),
|
||||
mcp.WithString("env", mcp.Description("Comma-separated KEY=VALUE environment variables")),
|
||||
mcp.WithNumber("timeout_seconds", mcp.Description("Job timeout in seconds (default 300)")),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *WebhookToolRegistrar) listK8sHandlersTool() mcp.Tool {
|
||||
return mcp.NewTool("list_k8s_handlers",
|
||||
mcp.WithDescription("List your registered Kubernetes Job handlers and their status."),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *WebhookToolRegistrar) deleteK8sHandlerTool() mcp.Tool {
|
||||
return mcp.NewTool("delete_k8s_handler",
|
||||
mcp.WithDescription("Delete one of your registered Kubernetes Job handlers by ID."),
|
||||
mcp.WithNumber("handler_id", mcp.Description("ID of the K8s handler to delete"), mcp.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
// --- K8s Handler Tool Handlers ---
|
||||
|
||||
func (r *WebhookToolRegistrar) handleRegisterK8sHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
image := req.GetString("image", "")
|
||||
eventsStr := req.GetString("events", "")
|
||||
|
||||
if image == "" {
|
||||
return mcp.NewToolResultError("'image' parameter is required"), nil
|
||||
}
|
||||
if eventsStr == "" {
|
||||
return mcp.NewToolResultError("'events' parameter is required"), nil
|
||||
}
|
||||
|
||||
events := parseEvents(eventsStr)
|
||||
|
||||
// Parse env vars
|
||||
envMap := make(map[string]string)
|
||||
if envStr := req.GetString("env", ""); envStr != "" {
|
||||
for _, pair := range strings.Split(envStr, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if parts := strings.SplitN(pair, "=", 2); len(parts) == 2 {
|
||||
envMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlerReq := k8s.RegisterHandlerRequest{
|
||||
Image: image,
|
||||
Events: events,
|
||||
Namespace: req.GetString("namespace", ""),
|
||||
ResourcesMemory: req.GetString("resources_memory", ""),
|
||||
ResourcesCPU: req.GetString("resources_cpu", ""),
|
||||
Env: envMap,
|
||||
TimeoutSeconds: req.GetInt("timeout_seconds", 300),
|
||||
}
|
||||
|
||||
handler, err := r.k8sService.RegisterHandler(ctx, agentName, handlerReq)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("register_k8s_handler failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"handler_id": handler.ID,
|
||||
"image": handler.Image,
|
||||
"events": handler.Events,
|
||||
"namespace": handler.Namespace,
|
||||
"timeout_seconds": handler.TimeoutSeconds,
|
||||
"status": handler.Status,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *WebhookToolRegistrar) handleListK8sHandlers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
handlers, err := r.k8sService.ListHandlers(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("list_k8s_handlers failed: %s", err)), nil
|
||||
}
|
||||
|
||||
result := make([]map[string]any, len(handlers))
|
||||
for i, h := range handlers {
|
||||
result[i] = map[string]any{
|
||||
"id": h.ID,
|
||||
"image": h.Image,
|
||||
"events": h.Events,
|
||||
"namespace": h.Namespace,
|
||||
"resources_memory": h.ResourcesMemory,
|
||||
"resources_cpu": h.ResourcesCPU,
|
||||
"timeout_seconds": h.TimeoutSeconds,
|
||||
"status": h.Status,
|
||||
"created_at": h.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"handlers": result,
|
||||
"count": len(result),
|
||||
"k8s_available": r.k8sService.IsAvailable(),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *WebhookToolRegistrar) handleDeleteK8sHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
handlerID, err := req.RequireInt("handler_id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("'handler_id' parameter is required"), nil
|
||||
}
|
||||
|
||||
if err := r.k8sService.DeleteHandler(ctx, agentName, int64(handlerID)); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("delete_k8s_handler failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"deleted": true,
|
||||
"handler_id": handlerID,
|
||||
})
|
||||
}
|
||||
|
||||
// parseEvents splits a comma-separated event string into a trimmed slice.
|
||||
func parseEvents(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
events := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
events = append(events, p)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -8,14 +8,16 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
// MessagingService provides business logic for messaging operations.
|
||||
type MessagingService struct {
|
||||
store MessageStore
|
||||
tracer *trace.Tracer
|
||||
logger *slog.Logger
|
||||
store MessageStore
|
||||
tracer *trace.Tracer
|
||||
dispatcher dispatcher.EventDispatcher
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewMessagingService creates a new messaging service.
|
||||
@@ -27,6 +29,11 @@ func NewMessagingService(store MessageStore, tracer *trace.Tracer) *MessagingSer
|
||||
}
|
||||
}
|
||||
|
||||
// SetDispatcher sets the event dispatcher for webhook/K8s delivery.
|
||||
func (s *MessagingService) SetDispatcher(d dispatcher.EventDispatcher) {
|
||||
s.dispatcher = d
|
||||
}
|
||||
|
||||
// SendMessage creates a message, auto-creating conversations as needed.
|
||||
func (s *MessagingService) SendMessage(ctx context.Context, from, to, body string, opts SendOptions) (*Message, error) {
|
||||
// Validate inputs
|
||||
@@ -132,6 +139,39 @@ func (s *MessagingService) SendMessage(ctx context.Context, from, to, body strin
|
||||
})
|
||||
}
|
||||
|
||||
// Dispatch event to webhooks/K8s (async, best-effort)
|
||||
if s.dispatcher != nil {
|
||||
eventType := "message.received"
|
||||
channel := ""
|
||||
if opts.ChannelID != nil {
|
||||
eventType = "channel.message"
|
||||
channel = fmt.Sprintf("%d", *opts.ChannelID)
|
||||
}
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: eventType,
|
||||
MessageID: msg.ID,
|
||||
FromAgent: from,
|
||||
ToAgent: to,
|
||||
Channel: channel,
|
||||
Body: body,
|
||||
Priority: priority,
|
||||
Metadata: string(metadata),
|
||||
}
|
||||
|
||||
// Extract @mentions for mention webhook events
|
||||
event.MentionedAgents = dispatcher.ExtractMentions(body)
|
||||
|
||||
go func() {
|
||||
if err := s.dispatcher.Dispatch(context.Background(), event); err != nil {
|
||||
s.logger.Error("event dispatch failed",
|
||||
"message_id", msg.ID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Webhook registrations
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL REFERENCES agents(name) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
secret_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(agent_name, url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_agent_name ON webhooks(agent_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_agent_status ON webhooks(agent_name, status);
|
||||
|
||||
-- Webhook delivery tracking
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
|
||||
agent_name TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'retrying', 'dead_lettered')),
|
||||
http_status INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
last_error TEXT,
|
||||
next_retry_at DATETIME,
|
||||
depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
delivered_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_webhook_id ON webhook_deliveries(webhook_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_status ON webhook_deliveries(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_agent_status ON webhook_deliveries(agent_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_next_retry ON webhook_deliveries(next_retry_at) WHERE status = 'retrying';
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_created_at ON webhook_deliveries(created_at);
|
||||
|
||||
-- K8s handler registrations
|
||||
CREATE TABLE IF NOT EXISTS k8s_handlers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL REFERENCES agents(name) ON DELETE CASCADE,
|
||||
image TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
namespace TEXT NOT NULL,
|
||||
resources_memory TEXT NOT NULL DEFAULT '256Mi',
|
||||
resources_cpu TEXT NOT NULL DEFAULT '100m',
|
||||
env TEXT NOT NULL DEFAULT '{}',
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 600 CHECK(timeout_seconds >= 60 AND timeout_seconds <= 3600),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(agent_name, image, namespace)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_k8s_handlers_agent ON k8s_handlers(agent_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_k8s_handlers_agent_status ON k8s_handlers(agent_name, status);
|
||||
|
||||
-- K8s job run tracking
|
||||
CREATE TABLE IF NOT EXISTS k8s_job_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
handler_id INTEGER NOT NULL REFERENCES k8s_handlers(id) ON DELETE CASCADE,
|
||||
agent_name TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
job_name TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'succeeded', 'failed')),
|
||||
failure_reason TEXT,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_handler ON k8s_job_runs(handler_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_agent_status ON k8s_job_runs(agent_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_job_name ON k8s_job_runs(job_name);
|
||||
Vendored
+11
-11
@@ -8,29 +8,29 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.CQTfZTwy.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DPW75Z9E.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/ClIHNghI.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DQXcvzYS.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C1o7UclM.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/D691nVsB.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/D273pRpB.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.BNVVG3sy.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.B4zIRc6l.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/k7nCSttu.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DBeLgT1-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/SAcaBy3_.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DL-Ee-iM.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BCvik_Lu.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BdrVqzRy.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.UyBC-CXX.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_19bfpjx = {
|
||||
__sveltekit_ymf88 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.CQTfZTwy.js"),
|
||||
import("/_app/immutable/entry/app.BNVVG3sy.js")
|
||||
import("/_app/immutable/entry/start.B4zIRc6l.js"),
|
||||
import("/_app/immutable/entry/app.UyBC-CXX.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
)
|
||||
|
||||
// Retry intervals for exponential backoff.
|
||||
var retryIntervals = []time.Duration{
|
||||
1 * time.Second,
|
||||
5 * time.Second,
|
||||
30 * time.Second,
|
||||
}
|
||||
|
||||
const (
|
||||
maxAttempts = 3
|
||||
defaultWorkers = 8
|
||||
queueSize = 1000
|
||||
)
|
||||
|
||||
// DeliveryPayload is the JSON body sent to webhook endpoints.
|
||||
type DeliveryPayload struct {
|
||||
Event string `json:"event"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
ToAgent string `json:"to_agent,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Priority int `json:"priority"`
|
||||
Metadata string `json:"metadata,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// DeliveryEngine implements dispatcher.EventDispatcher for webhooks.
|
||||
// It maintains a worker pool for async delivery with retry support.
|
||||
type DeliveryEngine struct {
|
||||
service *WebhookService
|
||||
rateLimiter *AgentRateLimiter
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
workers int
|
||||
|
||||
queue chan *deliveryWork
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type deliveryWork struct {
|
||||
delivery *WebhookDelivery
|
||||
webhook *Webhook
|
||||
secret string // raw secret (from registration) — we use SecretHash to sign
|
||||
}
|
||||
|
||||
// NewDeliveryEngine creates a new delivery engine.
|
||||
func NewDeliveryEngine(service *WebhookService, rateLimiter *AgentRateLimiter, allowPrivate bool) *DeliveryEngine {
|
||||
return &DeliveryEngine{
|
||||
service: service,
|
||||
rateLimiter: rateLimiter,
|
||||
httpClient: NewSSRFSafeClient(allowPrivate),
|
||||
logger: slog.Default().With("component", "webhook-delivery"),
|
||||
workers: defaultWorkers,
|
||||
queue: make(chan *deliveryWork, queueSize),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the worker pool and re-queues any pending/retrying deliveries from a previous run.
|
||||
func (e *DeliveryEngine) Start() {
|
||||
e.ctx, e.cancel = context.WithCancel(context.Background())
|
||||
for i := 0; i < e.workers; i++ {
|
||||
e.wg.Add(1)
|
||||
go e.worker(i)
|
||||
}
|
||||
// Start retry poller
|
||||
e.wg.Add(1)
|
||||
go e.retryPoller()
|
||||
// Start dead letter purge worker
|
||||
e.wg.Add(1)
|
||||
go e.deadLetterPurger()
|
||||
// Re-queue pending deliveries from previous run
|
||||
e.requeuePending()
|
||||
e.logger.Info("delivery engine started", "workers", e.workers)
|
||||
}
|
||||
|
||||
// requeuePending re-queues deliveries that were pending or retrying when the server last stopped.
|
||||
func (e *DeliveryEngine) requeuePending() {
|
||||
store := e.service.Store()
|
||||
ctx := e.ctx
|
||||
|
||||
pending, err := store.GetPendingDeliveries(ctx, 100)
|
||||
if err != nil {
|
||||
e.logger.Error("failed to re-queue pending deliveries", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
requeued := 0
|
||||
for _, d := range pending {
|
||||
wh, err := store.(*SQLiteWebhookStore).GetWebhookByID(ctx, d.WebhookID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case e.queue <- &deliveryWork{delivery: d, webhook: wh}:
|
||||
requeued++
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
if requeued > 0 {
|
||||
e.logger.Info("re-queued pending deliveries from previous run", "count", requeued)
|
||||
}
|
||||
}
|
||||
|
||||
// deadLetterPurger periodically purges old dead-lettered deliveries (>30 days).
|
||||
func (e *DeliveryEngine) deadLetterPurger() {
|
||||
defer e.wg.Done()
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
store := e.service.Store()
|
||||
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
||||
count, err := store.PurgeOldDeadLetters(e.ctx, cutoff)
|
||||
if err != nil {
|
||||
e.logger.Error("failed to purge old dead letters", "error", err)
|
||||
} else if count > 0 {
|
||||
e.logger.Info("purged old dead letters", "count", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the delivery engine.
|
||||
func (e *DeliveryEngine) Stop() {
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
}
|
||||
e.wg.Wait()
|
||||
e.logger.Info("delivery engine stopped")
|
||||
}
|
||||
|
||||
// Dispatch implements dispatcher.EventDispatcher. It enqueues webhook deliveries
|
||||
// for all active webhooks matching the event.
|
||||
func (e *DeliveryEngine) Dispatch(ctx context.Context, event dispatcher.MessageEvent) error {
|
||||
// Check depth limit for loop prevention
|
||||
if event.Depth >= MaxDepth {
|
||||
e.logger.Warn("webhook chain depth exceeded, dropping event",
|
||||
"event_type", event.EventType,
|
||||
"message_id", event.MessageID,
|
||||
"depth", event.Depth,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find matching webhooks for the target agent
|
||||
targetAgent := event.ToAgent
|
||||
if targetAgent == "" && event.Channel != "" {
|
||||
// For channel messages, we don't deliver webhooks (channel messages go to
|
||||
// individual agents via mention events). But we handle message.mentioned
|
||||
// events which target specific agents.
|
||||
return nil
|
||||
}
|
||||
|
||||
webhooks, err := e.service.GetActiveWebhooksForEvent(ctx, targetAgent, event.EventType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get webhooks for event: %w", err)
|
||||
}
|
||||
|
||||
if len(webhooks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build payload
|
||||
payload := DeliveryPayload{
|
||||
Event: event.EventType,
|
||||
MessageID: event.MessageID,
|
||||
FromAgent: event.FromAgent,
|
||||
ToAgent: event.ToAgent,
|
||||
Channel: event.Channel,
|
||||
Body: event.Body,
|
||||
Priority: event.Priority,
|
||||
Metadata: event.Metadata,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
store := e.service.Store()
|
||||
|
||||
for _, wh := range webhooks {
|
||||
delivery := &WebhookDelivery{
|
||||
WebhookID: wh.ID,
|
||||
AgentName: wh.AgentName,
|
||||
Event: event.EventType,
|
||||
MessageID: event.MessageID,
|
||||
Payload: string(payloadBytes),
|
||||
Status: DeliveryStatusPending,
|
||||
MaxAttempts: maxAttempts,
|
||||
Depth: event.Depth,
|
||||
}
|
||||
|
||||
if _, err := store.InsertDelivery(ctx, delivery); err != nil {
|
||||
e.logger.Error("failed to insert delivery",
|
||||
"webhook_id", wh.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Enqueue for async delivery
|
||||
select {
|
||||
case e.queue <- &deliveryWork{delivery: delivery, webhook: wh}:
|
||||
default:
|
||||
e.logger.Warn("delivery queue full, delivery will be picked up by retry poller",
|
||||
"delivery_id", delivery.ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DispatchMentions dispatches message.mentioned events for each @mentioned agent.
|
||||
func (e *DeliveryEngine) DispatchMentions(ctx context.Context, event dispatcher.MessageEvent) error {
|
||||
for _, mentioned := range event.MentionedAgents {
|
||||
mentionEvent := dispatcher.MessageEvent{
|
||||
EventType: "message.mentioned",
|
||||
MessageID: event.MessageID,
|
||||
FromAgent: event.FromAgent,
|
||||
ToAgent: mentioned,
|
||||
Channel: event.Channel,
|
||||
Body: event.Body,
|
||||
Priority: event.Priority,
|
||||
Metadata: event.Metadata,
|
||||
Depth: event.Depth,
|
||||
}
|
||||
if err := e.Dispatch(ctx, mentionEvent); err != nil {
|
||||
e.logger.Error("failed to dispatch mention event",
|
||||
"mentioned", mentioned,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *DeliveryEngine) worker(id int) {
|
||||
defer e.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
case work := <-e.queue:
|
||||
e.deliverOne(e.ctx, work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *DeliveryEngine) deliverOne(ctx context.Context, work *deliveryWork) {
|
||||
delivery := work.delivery
|
||||
wh := work.webhook
|
||||
store := e.service.Store()
|
||||
|
||||
// Rate limit
|
||||
if err := e.rateLimiter.Wait(ctx, delivery.AgentName); err != nil {
|
||||
e.logger.Error("rate limiter error", "agent", delivery.AgentName, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Increment attempt count
|
||||
delivery.Attempts++
|
||||
_ = store.UpdateDeliveryAttempts(ctx, delivery.ID, delivery.Attempts)
|
||||
|
||||
// Build HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.URL, bytes.NewReader([]byte(delivery.Payload)))
|
||||
if err != nil {
|
||||
e.recordFailure(ctx, delivery, wh, 0, fmt.Sprintf("build request: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-SynapBus-Event", delivery.Event)
|
||||
req.Header.Set("X-SynapBus-Delivery", fmt.Sprintf("%d", delivery.ID))
|
||||
req.Header.Set("X-SynapBus-Depth", strconv.Itoa(delivery.Depth + 1))
|
||||
|
||||
// HMAC signature using the stored secret hash as the key
|
||||
signature := ComputeHMACSignature([]byte(wh.SecretHash), []byte(delivery.Payload))
|
||||
req.Header.Set("X-SynapBus-Signature", signature)
|
||||
|
||||
// Execute request
|
||||
resp, err := e.httpClient.Do(req)
|
||||
if err != nil {
|
||||
e.recordFailure(ctx, delivery, wh, 0, fmt.Sprintf("HTTP request: %s", err))
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
// Success
|
||||
now := time.Now()
|
||||
_ = store.UpdateDeliveryStatus(ctx, delivery.ID, DeliveryStatusDelivered, resp.StatusCode, "", nil, &now)
|
||||
_ = e.service.RecordSuccess(ctx, wh.ID)
|
||||
|
||||
e.logger.Info("webhook delivered",
|
||||
"delivery_id", delivery.ID,
|
||||
"webhook_id", wh.ID,
|
||||
"status", resp.StatusCode,
|
||||
)
|
||||
} else {
|
||||
e.recordFailure(ctx, delivery, wh, resp.StatusCode, fmt.Sprintf("HTTP %d", resp.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *DeliveryEngine) recordFailure(ctx context.Context, delivery *WebhookDelivery, wh *Webhook, httpStatus int, errMsg string) {
|
||||
store := e.service.Store()
|
||||
|
||||
if delivery.Attempts < delivery.MaxAttempts {
|
||||
// Schedule retry
|
||||
retryIdx := delivery.Attempts - 1
|
||||
if retryIdx >= len(retryIntervals) {
|
||||
retryIdx = len(retryIntervals) - 1
|
||||
}
|
||||
nextRetry := time.Now().Add(retryIntervals[retryIdx])
|
||||
_ = store.UpdateDeliveryStatus(ctx, delivery.ID, DeliveryStatusRetrying, httpStatus, errMsg, &nextRetry, nil)
|
||||
|
||||
e.logger.Info("webhook delivery failed, scheduling retry",
|
||||
"delivery_id", delivery.ID,
|
||||
"attempt", delivery.Attempts,
|
||||
"next_retry", nextRetry,
|
||||
"error", errMsg,
|
||||
)
|
||||
} else {
|
||||
// Dead letter
|
||||
_ = store.UpdateDeliveryStatus(ctx, delivery.ID, DeliveryStatusDeadLettered, httpStatus, errMsg, nil, nil)
|
||||
_ = e.service.RecordFailure(ctx, wh.ID)
|
||||
|
||||
e.logger.Warn("webhook delivery dead-lettered",
|
||||
"delivery_id", delivery.ID,
|
||||
"webhook_id", wh.ID,
|
||||
"attempts", delivery.Attempts,
|
||||
"error", errMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *DeliveryEngine) retryPoller() {
|
||||
defer e.wg.Done()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
e.processRetries()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *DeliveryEngine) processRetries() {
|
||||
ctx := e.ctx
|
||||
store := e.service.Store()
|
||||
|
||||
deliveries, err := store.GetRetryableDeliveries(ctx, time.Now(), 50)
|
||||
if err != nil {
|
||||
e.logger.Error("failed to get retryable deliveries", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, d := range deliveries {
|
||||
wh, err := store.(*SQLiteWebhookStore).GetWebhookByID(ctx, d.WebhookID)
|
||||
if err != nil {
|
||||
e.logger.Error("failed to get webhook for retry",
|
||||
"delivery_id", d.ID,
|
||||
"webhook_id", d.WebhookID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case e.queue <- &deliveryWork{delivery: d, webhook: wh}:
|
||||
default:
|
||||
e.logger.Warn("delivery queue full during retry processing",
|
||||
"delivery_id", d.ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
)
|
||||
|
||||
func setupDeliveryTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create tables
|
||||
for _, ddl := range []string{
|
||||
`CREATE TABLE webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
secret_hash TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL,
|
||||
agent_name TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
http_status INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
last_error TEXT,
|
||||
next_retry_at DATETIME,
|
||||
depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
delivered_at DATETIME,
|
||||
FOREIGN KEY (webhook_id) REFERENCES webhooks(id)
|
||||
)`,
|
||||
} {
|
||||
if _, err := db.Exec(ddl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDeliveryEngine_SuccessfulDelivery(t *testing.T) {
|
||||
db := setupDeliveryTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
|
||||
var received atomic.Int32
|
||||
var receivedHeaders http.Header
|
||||
var receivedBody []byte
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
received.Add(1)
|
||||
receivedHeaders = r.Header.Clone()
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
receivedBody = buf[:n]
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Register webhook
|
||||
wh := &Webhook{
|
||||
AgentName: "test-agent",
|
||||
URL: srv.URL,
|
||||
Events: []string{"message.received"},
|
||||
SecretHash: "testhash",
|
||||
Status: WebhookStatusActive,
|
||||
}
|
||||
_, err := store.InsertWebhook(context.Background(), wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service := NewWebhookService(store, true, true)
|
||||
rateLimiter := NewAgentRateLimiter(60)
|
||||
engine := NewDeliveryEngine(service, rateLimiter, true)
|
||||
engine.Start()
|
||||
defer engine.Stop()
|
||||
|
||||
// Dispatch event
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 42,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "test-agent",
|
||||
Body: "hello world",
|
||||
Priority: 1,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
err = engine.Dispatch(context.Background(), event)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
|
||||
// Wait for delivery
|
||||
deadline := time.After(5 * time.Second)
|
||||
for received.Load() == 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for delivery")
|
||||
default:
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
if received.Load() != 1 {
|
||||
t.Errorf("expected 1 delivery, got %d", received.Load())
|
||||
}
|
||||
|
||||
// Verify headers
|
||||
if receivedHeaders.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %q", receivedHeaders.Get("Content-Type"))
|
||||
}
|
||||
if receivedHeaders.Get("X-Synapbus-Event") != "message.received" {
|
||||
t.Errorf("expected X-Synapbus-Event message.received, got %q", receivedHeaders.Get("X-Synapbus-Event"))
|
||||
}
|
||||
if receivedHeaders.Get("X-Synapbus-Signature") == "" {
|
||||
t.Error("expected X-Synapbus-Signature header to be set")
|
||||
}
|
||||
if receivedHeaders.Get("X-Synapbus-Depth") != "1" {
|
||||
t.Errorf("expected X-Synapbus-Depth 1, got %q", receivedHeaders.Get("X-Synapbus-Depth"))
|
||||
}
|
||||
|
||||
// Verify payload structure
|
||||
var payload DeliveryPayload
|
||||
if err := json.Unmarshal(receivedBody, &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if payload.Event != "message.received" {
|
||||
t.Errorf("expected event message.received, got %q", payload.Event)
|
||||
}
|
||||
if payload.MessageID != 42 {
|
||||
t.Errorf("expected message_id 42, got %d", payload.MessageID)
|
||||
}
|
||||
if payload.FromAgent != "sender" {
|
||||
t.Errorf("expected from_agent sender, got %q", payload.FromAgent)
|
||||
}
|
||||
if payload.Body != "hello world" {
|
||||
t.Errorf("expected body 'hello world', got %q", payload.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryEngine_DepthExceeded(t *testing.T) {
|
||||
db := setupDeliveryTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
|
||||
var received atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
received.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Register webhook
|
||||
wh := &Webhook{
|
||||
AgentName: "test-agent",
|
||||
URL: srv.URL,
|
||||
Events: []string{"message.received"},
|
||||
SecretHash: "testhash",
|
||||
Status: WebhookStatusActive,
|
||||
}
|
||||
_, err := store.InsertWebhook(context.Background(), wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service := NewWebhookService(store, true, true)
|
||||
rateLimiter := NewAgentRateLimiter(60)
|
||||
engine := NewDeliveryEngine(service, rateLimiter, true)
|
||||
engine.Start()
|
||||
defer engine.Stop()
|
||||
|
||||
// Depth 4 should succeed
|
||||
event4 := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 1,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "test-agent",
|
||||
Body: "depth 4",
|
||||
Depth: 4,
|
||||
}
|
||||
if err := engine.Dispatch(context.Background(), event4); err != nil {
|
||||
t.Fatalf("dispatch depth 4: %v", err)
|
||||
}
|
||||
|
||||
// Wait for depth 4 delivery
|
||||
deadline := time.After(5 * time.Second)
|
||||
for received.Load() == 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for depth 4 delivery")
|
||||
default:
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
if received.Load() != 1 {
|
||||
t.Errorf("expected 1 delivery for depth 4, got %d", received.Load())
|
||||
}
|
||||
|
||||
// Depth 5 should be dropped (MaxDepth = 5)
|
||||
event5 := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 2,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "test-agent",
|
||||
Body: "depth 5",
|
||||
Depth: 5,
|
||||
}
|
||||
if err := engine.Dispatch(context.Background(), event5); err != nil {
|
||||
t.Fatalf("dispatch depth 5: %v", err)
|
||||
}
|
||||
|
||||
// Short wait — delivery should NOT happen
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if received.Load() != 1 {
|
||||
t.Errorf("expected depth 5 to be dropped, but got %d deliveries", received.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryEngine_RetryOnFailure(t *testing.T) {
|
||||
db := setupDeliveryTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
|
||||
var attempts atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := attempts.Add(1)
|
||||
if count <= 2 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Register webhook
|
||||
wh := &Webhook{
|
||||
AgentName: "test-agent",
|
||||
URL: srv.URL,
|
||||
Events: []string{"message.received"},
|
||||
SecretHash: "testhash",
|
||||
Status: WebhookStatusActive,
|
||||
}
|
||||
_, err := store.InsertWebhook(context.Background(), wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service := NewWebhookService(store, true, true)
|
||||
rateLimiter := NewAgentRateLimiter(60)
|
||||
engine := NewDeliveryEngine(service, rateLimiter, true)
|
||||
|
||||
// Use shorter retry intervals for testing
|
||||
origIntervals := retryIntervals
|
||||
retryIntervals = []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 500 * time.Millisecond}
|
||||
defer func() { retryIntervals = origIntervals }()
|
||||
|
||||
engine.Start()
|
||||
defer engine.Stop()
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 1,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "test-agent",
|
||||
Body: "retry test",
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
if err := engine.Dispatch(context.Background(), event); err != nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
|
||||
// Wait for retries to complete (server returns 200 on 3rd attempt)
|
||||
deadline := time.After(15 * time.Second)
|
||||
for attempts.Load() < 3 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for retries, got %d attempts", attempts.Load())
|
||||
default:
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
if attempts.Load() < 3 {
|
||||
t.Errorf("expected at least 3 attempts, got %d", attempts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryEngine_DeadLetterAfterMaxRetries(t *testing.T) {
|
||||
db := setupDeliveryTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Register webhook
|
||||
wh := &Webhook{
|
||||
AgentName: "test-agent",
|
||||
URL: srv.URL,
|
||||
Events: []string{"message.received"},
|
||||
SecretHash: "testhash",
|
||||
Status: WebhookStatusActive,
|
||||
}
|
||||
_, err := store.InsertWebhook(context.Background(), wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service := NewWebhookService(store, true, true)
|
||||
rateLimiter := NewAgentRateLimiter(60)
|
||||
engine := NewDeliveryEngine(service, rateLimiter, true)
|
||||
|
||||
// Use shorter retry intervals for testing
|
||||
origIntervals := retryIntervals
|
||||
retryIntervals = []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 500 * time.Millisecond}
|
||||
defer func() { retryIntervals = origIntervals }()
|
||||
|
||||
engine.Start()
|
||||
defer engine.Stop()
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 1,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "test-agent",
|
||||
Body: "dead letter test",
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
if err := engine.Dispatch(context.Background(), event); err != nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
|
||||
// Wait for all retries to exhaust
|
||||
deadline := time.After(20 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for dead letter")
|
||||
default:
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
deliveries, err := store.GetDeliveriesByAgent(context.Background(), "test-agent", DeliveryStatusDeadLettered, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("get deliveries: %v", err)
|
||||
}
|
||||
if len(deliveries) > 0 {
|
||||
if deliveries[0].Attempts < maxAttempts {
|
||||
t.Errorf("expected %d attempts, got %d", maxAttempts, deliveries[0].Attempts)
|
||||
}
|
||||
return // Test passes
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package webhooks implements the webhook delivery engine for SynapBus.
|
||||
// It provides webhook registration, HMAC-signed delivery, SSRF protection,
|
||||
// rate limiting, retry with exponential backoff, and dead letter management.
|
||||
package webhooks
|
||||
@@ -0,0 +1,53 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// AgentRateLimiter manages per-agent rate limiters for webhook delivery.
|
||||
// Each agent gets a token bucket allowing 60 deliveries per minute (1/second sustained)
|
||||
// with burst capacity of 60.
|
||||
type AgentRateLimiter struct {
|
||||
limiters sync.Map // map[string]*rate.Limiter
|
||||
rate rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
// NewAgentRateLimiter creates a new rate limiter with the given rate and burst.
|
||||
// Default: 1 per second sustained, burst of 60.
|
||||
func NewAgentRateLimiter(perMinute int) *AgentRateLimiter {
|
||||
return &AgentRateLimiter{
|
||||
rate: rate.Every(time.Minute / time.Duration(perMinute)),
|
||||
burst: perMinute,
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the agent is allowed to make a delivery, or the context is cancelled.
|
||||
func (r *AgentRateLimiter) Wait(ctx context.Context, agentName string) error {
|
||||
limiter := r.getLimiter(agentName)
|
||||
return limiter.Wait(ctx)
|
||||
}
|
||||
|
||||
// Allow checks if the agent can make a delivery without blocking.
|
||||
func (r *AgentRateLimiter) Allow(agentName string) bool {
|
||||
limiter := r.getLimiter(agentName)
|
||||
return limiter.Allow()
|
||||
}
|
||||
|
||||
// Remove removes the rate limiter for an agent (cleanup when agent has no webhooks).
|
||||
func (r *AgentRateLimiter) Remove(agentName string) {
|
||||
r.limiters.Delete(agentName)
|
||||
}
|
||||
|
||||
func (r *AgentRateLimiter) getLimiter(agentName string) *rate.Limiter {
|
||||
if v, ok := r.limiters.Load(agentName); ok {
|
||||
return v.(*rate.Limiter)
|
||||
}
|
||||
limiter := rate.NewLimiter(r.rate, r.burst)
|
||||
actual, _ := r.limiters.LoadOrStore(agentName, limiter)
|
||||
return actual.(*rate.Limiter)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRateLimiterAllow(t *testing.T) {
|
||||
// Create a limiter with 60 per minute (burst of 60)
|
||||
rl := NewAgentRateLimiter(60)
|
||||
|
||||
// First call should be allowed (burst available)
|
||||
if !rl.Allow("agent-1") {
|
||||
t.Error("first Allow() should return true (burst available)")
|
||||
}
|
||||
|
||||
// Multiple rapid calls should be allowed up to burst capacity
|
||||
allowed := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
if rl.Allow("agent-2") {
|
||||
allowed++
|
||||
}
|
||||
}
|
||||
// The burst is 60, so we should get at most 60 allowed
|
||||
if allowed > 60 {
|
||||
t.Errorf("allowed %d calls, expected at most 60 (burst)", allowed)
|
||||
}
|
||||
if allowed < 50 {
|
||||
t.Errorf("allowed %d calls, expected around 60 (burst)", allowed)
|
||||
}
|
||||
|
||||
// Different agents have independent limits
|
||||
if !rl.Allow("agent-3") {
|
||||
t.Error("new agent should have full burst available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterRemove(t *testing.T) {
|
||||
rl := NewAgentRateLimiter(60)
|
||||
|
||||
// Exhaust burst for agent-1
|
||||
for i := 0; i < 100; i++ {
|
||||
rl.Allow("agent-remove")
|
||||
}
|
||||
|
||||
// Remove the limiter
|
||||
rl.Remove("agent-remove")
|
||||
|
||||
// After removal, a new limiter is created with full burst
|
||||
if !rl.Allow("agent-remove") {
|
||||
t.Error("after Remove(), agent should get a fresh limiter with full burst")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ComputeHMACSignature computes the HMAC-SHA256 signature of the payload using the given secret.
|
||||
// Returns the signature as "sha256=<hex digest>".
|
||||
func ComputeHMACSignature(secret, payload []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write(payload)
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// ValidateWebhookURL validates a webhook URL for registration.
|
||||
// It checks scheme (HTTPS required unless allowHTTP), parses the URL,
|
||||
// and optionally resolves DNS to check for private IPs.
|
||||
func ValidateWebhookURL(rawURL string, allowHTTP, allowPrivate bool) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Check scheme
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
// Always allowed
|
||||
case "http":
|
||||
if !allowHTTP {
|
||||
return fmt.Errorf("webhook URL must use HTTPS (set SYNAPBUS_ALLOW_HTTP_WEBHOOKS=true for development)")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("webhook URL must use HTTPS scheme, got %q", u.Scheme)
|
||||
}
|
||||
|
||||
// Check host is not empty
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("webhook URL must have a hostname")
|
||||
}
|
||||
|
||||
// If allowPrivate, skip IP validation
|
||||
if allowPrivate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if host is a literal IP
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if IsPrivateIP(ip) {
|
||||
return fmt.Errorf("webhook URL resolves to a private network address; set SYNAPBUS_ALLOW_PRIVATE_NETWORKS=true to override")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve hostname and check all IPs
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
// Don't fail registration on DNS lookup failure — the URL might be valid later.
|
||||
// We'll re-check at delivery time.
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, ipStr := range ips {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip != nil && IsPrivateIP(ip) {
|
||||
return fmt.Errorf("webhook URL resolves to a private network address; set SYNAPBUS_ALLOW_PRIVATE_NETWORKS=true to override")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPrivateIP checks whether an IP address belongs to a private/reserved range.
|
||||
// Blocks: RFC1918, loopback, link-local, IPv6 ULA, IPv6 link-local.
|
||||
func IsPrivateIP(ip net.IP) bool {
|
||||
privateRanges := []struct {
|
||||
network *net.IPNet
|
||||
}{
|
||||
{mustParseCIDR("10.0.0.0/8")},
|
||||
{mustParseCIDR("172.16.0.0/12")},
|
||||
{mustParseCIDR("192.168.0.0/16")},
|
||||
{mustParseCIDR("127.0.0.0/8")},
|
||||
{mustParseCIDR("169.254.0.0/16")},
|
||||
{mustParseCIDR("::1/128")},
|
||||
{mustParseCIDR("fc00::/7")},
|
||||
{mustParseCIDR("fe80::/10")},
|
||||
}
|
||||
|
||||
for _, r := range privateRanges {
|
||||
if r.network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustParseCIDR(s string) *net.IPNet {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid CIDR %q: %v", s, err))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// NewSSRFSafeTransport creates an http.Transport that blocks connections to private IP addresses.
|
||||
// It uses a custom DialContext that resolves DNS and validates the IP before connecting.
|
||||
func NewSSRFSafeTransport(allowPrivate bool) *http.Transport {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if !allowPrivate {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
// Resolve DNS
|
||||
ips, err := net.DefaultResolver.LookupHost(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DNS resolution failed for %q: %w", host, err)
|
||||
}
|
||||
|
||||
// Check all resolved IPs
|
||||
for _, ipStr := range ips {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip != nil && IsPrivateIP(ip) {
|
||||
return nil, fmt.Errorf("webhook URL resolved to blocked IP address (%s)", ipStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to the first valid IP
|
||||
if len(ips) > 0 {
|
||||
addr = net.JoinHostPort(ips[0], port)
|
||||
}
|
||||
}
|
||||
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
},
|
||||
// Also use Control to catch any remaining private IPs at the socket level
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSSRFSafeClient creates an http.Client with SSRF protection and no redirect following.
|
||||
func NewSSRFSafeClient(allowPrivate bool) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: NewSSRFSafeTransport(allowPrivate),
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return fmt.Errorf("redirects not allowed")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// controlFunc returns a syscall.RawConn Control function that blocks private IPs.
|
||||
// This is a defense-in-depth check at the socket level.
|
||||
func controlFunc(network string, address string, conn syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
// address might not have a port
|
||||
host = address
|
||||
}
|
||||
// Remove brackets from IPv6
|
||||
host = strings.Trim(host, "[]")
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip != nil && IsPrivateIP(ip) {
|
||||
return fmt.Errorf("connection to private IP %s blocked", host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidURLSchemes contains the allowed URL schemes for webhooks.
|
||||
var ValidURLSchemes = []string{"https", "http"}
|
||||
|
||||
// ValidEvents contains the valid webhook event types.
|
||||
var ValidEvents = []string{"message.received", "message.mentioned", "channel.message"}
|
||||
|
||||
// IsValidEvent checks if the given event type is valid.
|
||||
func IsValidEvent(event string) bool {
|
||||
for _, e := range ValidEvents {
|
||||
if e == event {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeHMACSignature(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
payload string
|
||||
}{
|
||||
{name: "basic signature", secret: "my-secret", payload: `{"event":"message.received"}`},
|
||||
{name: "empty payload", secret: "key", payload: ""},
|
||||
{name: "empty secret", secret: "", payload: "data"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sig := ComputeHMACSignature([]byte(tt.secret), []byte(tt.payload))
|
||||
if len(sig) < 10 {
|
||||
t.Errorf("signature too short: %q", sig)
|
||||
}
|
||||
if sig[:7] != "sha256=" {
|
||||
t.Errorf("signature should start with 'sha256=', got %q", sig[:7])
|
||||
}
|
||||
|
||||
// Same inputs produce same output (deterministic)
|
||||
sig2 := ComputeHMACSignature([]byte(tt.secret), []byte(tt.payload))
|
||||
if sig != sig2 {
|
||||
t.Errorf("non-deterministic: %q != %q", sig, sig2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Different secrets produce different signatures
|
||||
sig1 := ComputeHMACSignature([]byte("secret-a"), []byte("payload"))
|
||||
sig2 := ComputeHMACSignature([]byte("secret-b"), []byte("payload"))
|
||||
if sig1 == sig2 {
|
||||
t.Error("different secrets should produce different signatures")
|
||||
}
|
||||
|
||||
// Different payloads produce different signatures
|
||||
sig3 := ComputeHMACSignature([]byte("key"), []byte("payload-a"))
|
||||
sig4 := ComputeHMACSignature([]byte("key"), []byte("payload-b"))
|
||||
if sig3 == sig4 {
|
||||
t.Error("different payloads should produce different signatures")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWebhookURL_HTTPS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid HTTPS", url: "https://example.com/webhook", wantErr: false},
|
||||
{name: "HTTPS with port", url: "https://example.com:8443/hook", wantErr: false},
|
||||
{name: "HTTPS with path", url: "https://hooks.example.com/v1/receive", wantErr: false},
|
||||
{name: "HTTP rejected when allowHTTP=false", url: "http://example.com/webhook", wantErr: true},
|
||||
{name: "FTP rejected", url: "ftp://example.com/file", wantErr: true},
|
||||
{name: "no scheme", url: "example.com/webhook", wantErr: true},
|
||||
{name: "empty URL", url: "", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateWebhookURL(tt.url, false, true) // allowHTTP=false, allowPrivate=true
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWebhookURL_HTTP_Allowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "HTTP allowed", url: "http://example.com/webhook", wantErr: false},
|
||||
{name: "HTTPS still works", url: "https://example.com/webhook", wantErr: false},
|
||||
{name: "FTP still rejected", url: "ftp://example.com/file", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateWebhookURL(tt.url, true, true) // allowHTTP=true, allowPrivate=true
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWebhookURL_PrivateIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "public IP allowed", url: "https://93.184.216.34/hook", wantErr: false},
|
||||
{name: "loopback blocked", url: "https://127.0.0.1/hook", wantErr: true},
|
||||
{name: "RFC1918 10.x blocked", url: "https://10.0.0.1/hook", wantErr: true},
|
||||
{name: "RFC1918 172.16.x blocked", url: "https://172.16.0.1/hook", wantErr: true},
|
||||
{name: "RFC1918 192.168.x blocked", url: "https://192.168.1.1/hook", wantErr: true},
|
||||
{name: "link-local blocked", url: "https://169.254.0.1/hook", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateWebhookURL(tt.url, true, false) // allowHTTP=true, allowPrivate=false
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
// RFC1918
|
||||
{name: "10.0.0.0/8 start", ip: "10.0.0.1", want: true},
|
||||
{name: "10.0.0.0/8 end", ip: "10.255.255.255", want: true},
|
||||
{name: "172.16.0.0/12 start", ip: "172.16.0.1", want: true},
|
||||
{name: "172.31.255.255 end", ip: "172.31.255.255", want: true},
|
||||
{name: "172.32.0.1 not private", ip: "172.32.0.1", want: false},
|
||||
{name: "192.168.0.0/16 start", ip: "192.168.0.1", want: true},
|
||||
{name: "192.168.255.255 end", ip: "192.168.255.255", want: true},
|
||||
|
||||
// Loopback
|
||||
{name: "loopback 127.0.0.1", ip: "127.0.0.1", want: true},
|
||||
{name: "loopback 127.255.255.255", ip: "127.255.255.255", want: true},
|
||||
|
||||
// Link-local
|
||||
{name: "link-local 169.254.0.1", ip: "169.254.0.1", want: true},
|
||||
{name: "link-local 169.254.255.255", ip: "169.254.255.255", want: true},
|
||||
|
||||
// Public IPs
|
||||
{name: "public 8.8.8.8", ip: "8.8.8.8", want: false},
|
||||
{name: "public 93.184.216.34", ip: "93.184.216.34", want: false},
|
||||
{name: "public 1.1.1.1", ip: "1.1.1.1", want: false},
|
||||
|
||||
// IPv6
|
||||
{name: "IPv6 loopback", ip: "::1", want: true},
|
||||
{name: "IPv6 ULA fc00::", ip: "fc00::1", want: true},
|
||||
{name: "IPv6 ULA fd00::", ip: "fd00::1", want: true},
|
||||
{name: "IPv6 link-local fe80::", ip: "fe80::1", want: true},
|
||||
{name: "IPv6 public 2001:db8::1", ip: "2001:db8::1", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP %q", tt.ip)
|
||||
}
|
||||
got := IsPrivateIP(ip)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsPrivateIP(%s) = %v, want %v", tt.ip, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
want bool
|
||||
}{
|
||||
{name: "message.received", event: "message.received", want: true},
|
||||
{name: "message.mentioned", event: "message.mentioned", want: true},
|
||||
{name: "channel.message", event: "channel.message", want: true},
|
||||
{name: "invalid event", event: "invalid.event", want: false},
|
||||
{name: "empty string", event: "", want: false},
|
||||
{name: "close but wrong", event: "message.sent", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsValidEvent(tt.event)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsValidEvent(%q) = %v, want %v", tt.event, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxWebhooksPerAgent is the maximum number of webhooks per agent.
|
||||
MaxWebhooksPerAgent = 3
|
||||
// MaxDepth is the maximum webhook chain depth for loop prevention.
|
||||
MaxDepth = 5
|
||||
// AutoDisableThreshold is the consecutive failure count that triggers auto-disable.
|
||||
AutoDisableThreshold = 50
|
||||
)
|
||||
|
||||
// WebhookService provides business logic for webhook operations.
|
||||
type WebhookService struct {
|
||||
store WebhookStore
|
||||
allowHTTP bool
|
||||
allowPrivate bool
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWebhookService creates a new webhook service.
|
||||
func NewWebhookService(store WebhookStore, allowHTTP, allowPrivate bool) *WebhookService {
|
||||
return &WebhookService{
|
||||
store: store,
|
||||
allowHTTP: allowHTTP,
|
||||
allowPrivate: allowPrivate,
|
||||
logger: slog.Default().With("component", "webhooks"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterWebhook registers a new webhook for an agent.
|
||||
func (s *WebhookService) RegisterWebhook(ctx context.Context, agentName, url string, events []string, secret string) (*Webhook, error) {
|
||||
// Validate events
|
||||
for _, e := range events {
|
||||
if !IsValidEvent(e) {
|
||||
return nil, fmt.Errorf("invalid event type: %q (valid: %v)", e, ValidEvents)
|
||||
}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return nil, fmt.Errorf("at least one event type is required")
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
if err := ValidateWebhookURL(url, s.allowHTTP, s.allowPrivate); err != nil {
|
||||
return nil, fmt.Errorf("invalid webhook URL: %w", err)
|
||||
}
|
||||
|
||||
// Check max webhooks
|
||||
count, err := s.store.CountWebhooksByAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count webhooks: %w", err)
|
||||
}
|
||||
if count >= MaxWebhooksPerAgent {
|
||||
return nil, fmt.Errorf("maximum webhooks reached (%d/%d); delete one before registering another", count, MaxWebhooksPerAgent)
|
||||
}
|
||||
|
||||
// Hash the secret for storage
|
||||
secretHash := hashSecret(secret)
|
||||
|
||||
wh := &Webhook{
|
||||
AgentName: agentName,
|
||||
URL: url,
|
||||
Events: events,
|
||||
SecretHash: secretHash,
|
||||
Status: WebhookStatusActive,
|
||||
}
|
||||
|
||||
id, err := s.store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert webhook: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("webhook registered",
|
||||
"id", id,
|
||||
"agent", agentName,
|
||||
"url", url,
|
||||
"events", events,
|
||||
)
|
||||
|
||||
return wh, nil
|
||||
}
|
||||
|
||||
// ListWebhooks returns all webhooks for an agent.
|
||||
func (s *WebhookService) ListWebhooks(ctx context.Context, agentName string) ([]*Webhook, error) {
|
||||
return s.store.GetWebhooksByAgent(ctx, agentName)
|
||||
}
|
||||
|
||||
// DeleteWebhook deletes a webhook owned by the agent.
|
||||
func (s *WebhookService) DeleteWebhook(ctx context.Context, agentName string, webhookID int64) error {
|
||||
if err := s.store.DeleteWebhook(ctx, webhookID, agentName); err != nil {
|
||||
return fmt.Errorf("delete webhook: %w", err)
|
||||
}
|
||||
s.logger.Info("webhook deleted",
|
||||
"id", webhookID,
|
||||
"agent", agentName,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveWebhooksForEvent returns active webhooks for an agent subscribed to a specific event.
|
||||
func (s *WebhookService) GetActiveWebhooksForEvent(ctx context.Context, agentName, event string) ([]*Webhook, error) {
|
||||
return s.store.GetActiveWebhooksByEvent(ctx, agentName, event)
|
||||
}
|
||||
|
||||
// RecordSuccess records a successful delivery: resets consecutive failures.
|
||||
func (s *WebhookService) RecordSuccess(ctx context.Context, webhookID int64) error {
|
||||
return s.store.ResetConsecutiveFailures(ctx, webhookID)
|
||||
}
|
||||
|
||||
// RecordFailure increments the failure counter and auto-disables if threshold reached.
|
||||
func (s *WebhookService) RecordFailure(ctx context.Context, webhookID int64) error {
|
||||
count, err := s.store.IncrementConsecutiveFailures(ctx, webhookID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= AutoDisableThreshold {
|
||||
s.logger.Warn("auto-disabling webhook after consecutive failures",
|
||||
"webhook_id", webhookID,
|
||||
"failures", count,
|
||||
)
|
||||
return s.store.UpdateWebhookStatus(ctx, webhookID, WebhookStatusDisabled)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store returns the underlying webhook store (for delivery engine access).
|
||||
func (s *WebhookService) Store() WebhookStore {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// hashSecret computes SHA-256 hex digest of the secret for storage.
|
||||
func hashSecret(secret string) string {
|
||||
h := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegisterWebhook_Success(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
svc := NewWebhookService(store, true, true) // allow HTTP + private for testing
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-agent")
|
||||
|
||||
wh, err := svc.RegisterWebhook(ctx, "svc-agent", "https://example.com/hook", []string{"message.received"}, "my-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterWebhook() error = %v", err)
|
||||
}
|
||||
if wh.ID <= 0 {
|
||||
t.Errorf("expected positive ID, got %d", wh.ID)
|
||||
}
|
||||
if wh.AgentName != "svc-agent" {
|
||||
t.Errorf("AgentName = %q, want %q", wh.AgentName, "svc-agent")
|
||||
}
|
||||
if wh.URL != "https://example.com/hook" {
|
||||
t.Errorf("URL = %q, want %q", wh.URL, "https://example.com/hook")
|
||||
}
|
||||
if wh.Status != WebhookStatusActive {
|
||||
t.Errorf("Status = %q, want %q", wh.Status, WebhookStatusActive)
|
||||
}
|
||||
if len(wh.Events) != 1 || wh.Events[0] != "message.received" {
|
||||
t.Errorf("Events = %v, want [message.received]", wh.Events)
|
||||
}
|
||||
if wh.SecretHash == "" {
|
||||
t.Error("SecretHash should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWebhook_InvalidEvent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
svc := NewWebhookService(store, true, true)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-agent2")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
events []string
|
||||
}{
|
||||
{name: "invalid event type", events: []string{"invalid.event"}},
|
||||
{name: "empty events", events: []string{}},
|
||||
{name: "mix of valid and invalid", events: []string{"message.received", "bogus"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := svc.RegisterWebhook(ctx, "svc-agent2", "https://example.com/hook", tt.events, "secret")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid events, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWebhook_InvalidURL(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
// Disallow HTTP to test HTTPS enforcement
|
||||
svc := NewWebhookService(store, false, true)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-agent3")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{name: "HTTP when not allowed", url: "http://example.com/hook"},
|
||||
{name: "FTP scheme", url: "ftp://example.com/hook"},
|
||||
{name: "no scheme", url: "example.com/hook"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := svc.RegisterWebhook(ctx, "svc-agent3", tt.url, []string{"message.received"}, "secret")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for URL %q, got nil", tt.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWebhook_MaxWebhooks(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
svc := NewWebhookService(store, true, true)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-agent4")
|
||||
|
||||
// Register MaxWebhooksPerAgent webhooks
|
||||
for i := 0; i < MaxWebhooksPerAgent; i++ {
|
||||
url := "https://example.com/hook" + string(rune('a'+i))
|
||||
_, err := svc.RegisterWebhook(ctx, "svc-agent4", url, []string{"message.received"}, "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("register webhook %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Next one should fail
|
||||
_, err := svc.RegisterWebhook(ctx, "svc-agent4", "https://example.com/one-too-many", []string{"message.received"}, "secret")
|
||||
if err == nil {
|
||||
t.Error("expected error when exceeding max webhooks, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWebhook_Service(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
svc := NewWebhookService(store, true, true)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-agent5")
|
||||
seedAgent(t, db, "svc-agent6")
|
||||
|
||||
wh, err := svc.RegisterWebhook(ctx, "svc-agent5", "https://example.com/del", []string{"message.received"}, "secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wrong owner
|
||||
err = svc.DeleteWebhook(ctx, "svc-agent6", wh.ID)
|
||||
if err == nil {
|
||||
t.Error("expected error when deleting with wrong owner")
|
||||
}
|
||||
|
||||
// Correct owner
|
||||
err = svc.DeleteWebhook(ctx, "svc-agent5", wh.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteWebhook() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
webhooks, err := svc.ListWebhooks(ctx, "svc-agent5")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webhooks) != 0 {
|
||||
t.Errorf("expected 0 webhooks after delete, got %d", len(webhooks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWebhooks(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
svc := NewWebhookService(store, true, true)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-agent7")
|
||||
|
||||
// Empty list initially
|
||||
webhooks, err := svc.ListWebhooks(ctx, "svc-agent7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webhooks) != 0 {
|
||||
t.Errorf("expected 0 webhooks initially, got %d", len(webhooks))
|
||||
}
|
||||
|
||||
// Register two
|
||||
for _, url := range []string{"https://example.com/list1", "https://example.com/list2"} {
|
||||
if _, err := svc.RegisterWebhook(ctx, "svc-agent7", url, []string{"message.received"}, "secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
webhooks, err = svc.ListWebhooks(ctx, "svc-agent7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webhooks) != 2 {
|
||||
t.Errorf("expected 2 webhooks, got %d", len(webhooks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFailure_AutoDisable(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
svc := NewWebhookService(store, true, true)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-agent8")
|
||||
|
||||
wh, err := svc.RegisterWebhook(ctx, "svc-agent8", "https://example.com/fail", []string{"message.received"}, "secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Record failures up to threshold
|
||||
for i := 0; i < AutoDisableThreshold; i++ {
|
||||
if err := svc.RecordFailure(ctx, wh.ID); err != nil {
|
||||
t.Fatalf("RecordFailure iteration %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify webhook is now disabled
|
||||
got, err := store.GetWebhookByID(ctx, wh.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != WebhookStatusDisabled {
|
||||
t.Errorf("Status = %q after %d failures, want %q", got.Status, AutoDisableThreshold, WebhookStatusDisabled)
|
||||
}
|
||||
if got.ConsecutiveFailures < AutoDisableThreshold {
|
||||
t.Errorf("ConsecutiveFailures = %d, want >= %d", got.ConsecutiveFailures, AutoDisableThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSuccess_ResetsFailures(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
svc := NewWebhookService(store, true, true)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "svc-agent9")
|
||||
|
||||
wh, err := svc.RegisterWebhook(ctx, "svc-agent9", "https://example.com/success", []string{"message.received"}, "secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Record a few failures
|
||||
for i := 0; i < 10; i++ {
|
||||
if err := svc.RecordFailure(ctx, wh.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Record success
|
||||
if err := svc.RecordSuccess(ctx, wh.ID); err != nil {
|
||||
t.Fatalf("RecordSuccess() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetWebhookByID(ctx, wh.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ConsecutiveFailures != 0 {
|
||||
t.Errorf("ConsecutiveFailures = %d after success, want 0", got.ConsecutiveFailures)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Webhook status constants.
|
||||
const (
|
||||
WebhookStatusActive = "active"
|
||||
WebhookStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
// Delivery status constants.
|
||||
const (
|
||||
DeliveryStatusPending = "pending"
|
||||
DeliveryStatusDelivered = "delivered"
|
||||
DeliveryStatusRetrying = "retrying"
|
||||
DeliveryStatusDeadLettered = "dead_lettered"
|
||||
)
|
||||
|
||||
// Webhook represents a registered webhook endpoint for an agent.
|
||||
type Webhook struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
URL string `json:"url"`
|
||||
Events []string `json:"events"`
|
||||
SecretHash string `json:"-"`
|
||||
Status string `json:"status"`
|
||||
ConsecutiveFailures int `json:"consecutive_failures"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// WebhookDelivery represents a single delivery attempt for a webhook.
|
||||
type WebhookDelivery struct {
|
||||
ID int64 `json:"id"`
|
||||
WebhookID int64 `json:"webhook_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Event string `json:"event"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
Payload string `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
Attempts int `json:"attempts"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
|
||||
Depth int `json:"depth"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookStore defines the storage interface for webhook operations.
|
||||
type WebhookStore interface {
|
||||
InsertWebhook(ctx context.Context, webhook *Webhook) (int64, error)
|
||||
GetWebhookByID(ctx context.Context, id int64) (*Webhook, error)
|
||||
GetWebhooksByAgent(ctx context.Context, agentName string) ([]*Webhook, error)
|
||||
GetActiveWebhooksByEvent(ctx context.Context, agentName string, event string) ([]*Webhook, error)
|
||||
UpdateWebhookStatus(ctx context.Context, id int64, status string) error
|
||||
IncrementConsecutiveFailures(ctx context.Context, id int64) (int, error)
|
||||
ResetConsecutiveFailures(ctx context.Context, id int64) error
|
||||
DeleteWebhook(ctx context.Context, id int64, agentName string) error
|
||||
CountWebhooksByAgent(ctx context.Context, agentName string) (int, error)
|
||||
InsertDelivery(ctx context.Context, delivery *WebhookDelivery) (int64, error)
|
||||
UpdateDeliveryStatus(ctx context.Context, id int64, status string, httpStatus int, lastError string, nextRetryAt *time.Time, deliveredAt *time.Time) error
|
||||
UpdateDeliveryAttempts(ctx context.Context, id int64, attempts int) error
|
||||
GetPendingDeliveries(ctx context.Context, limit int) ([]*WebhookDelivery, error)
|
||||
GetRetryableDeliveries(ctx context.Context, now time.Time, limit int) ([]*WebhookDelivery, error)
|
||||
GetDeliveriesByAgent(ctx context.Context, agentName string, status string, limit int) ([]*WebhookDelivery, error)
|
||||
GetDeliveryByID(ctx context.Context, id int64) (*WebhookDelivery, error)
|
||||
PurgeOldDeadLetters(ctx context.Context, olderThan time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// SQLiteWebhookStore implements WebhookStore using SQLite.
|
||||
type SQLiteWebhookStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteWebhookStore creates a new SQLite-backed webhook store.
|
||||
func NewSQLiteWebhookStore(db *sql.DB) *SQLiteWebhookStore {
|
||||
return &SQLiteWebhookStore{db: db}
|
||||
}
|
||||
|
||||
// InsertWebhook inserts a new webhook and returns its ID.
|
||||
func (s *SQLiteWebhookStore) InsertWebhook(ctx context.Context, webhook *Webhook) (int64, error) {
|
||||
eventsJSON, err := json.Marshal(webhook.Events)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal webhook events: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO webhooks (agent_name, url, events, secret_hash, status, consecutive_failures, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
webhook.AgentName, webhook.URL, string(eventsJSON), webhook.SecretHash, webhook.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert webhook: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get webhook id: %w", err)
|
||||
}
|
||||
webhook.ID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetWebhookByID retrieves a webhook by its ID.
|
||||
func (s *SQLiteWebhookStore) GetWebhookByID(ctx context.Context, id int64) (*Webhook, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, agent_name, url, events, secret_hash, status, consecutive_failures, created_at, updated_at
|
||||
FROM webhooks WHERE id = ?`, id,
|
||||
)
|
||||
return scanWebhook(row)
|
||||
}
|
||||
|
||||
// GetWebhooksByAgent retrieves all webhooks for an agent.
|
||||
func (s *SQLiteWebhookStore) GetWebhooksByAgent(ctx context.Context, agentName string) ([]*Webhook, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, agent_name, url, events, secret_hash, status, consecutive_failures, created_at, updated_at
|
||||
FROM webhooks WHERE agent_name = ?
|
||||
ORDER BY created_at ASC`, agentName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get webhooks by agent: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanWebhooks(rows)
|
||||
}
|
||||
|
||||
// GetActiveWebhooksByEvent retrieves active webhooks for an agent that are subscribed to a given event.
|
||||
// Since events is a JSON array stored as TEXT and there are few webhooks per agent,
|
||||
// we fetch all active webhooks for the agent and filter in Go.
|
||||
func (s *SQLiteWebhookStore) GetActiveWebhooksByEvent(ctx context.Context, agentName string, event string) ([]*Webhook, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, agent_name, url, events, secret_hash, status, consecutive_failures, created_at, updated_at
|
||||
FROM webhooks WHERE agent_name = ? AND status = 'active'
|
||||
ORDER BY created_at ASC`, agentName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active webhooks by event: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
all, err := scanWebhooks(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var matched []*Webhook
|
||||
for _, wh := range all {
|
||||
for _, e := range wh.Events {
|
||||
if e == event {
|
||||
matched = append(matched, wh)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
matched = []*Webhook{}
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
// UpdateWebhookStatus updates the status of a webhook.
|
||||
func (s *SQLiteWebhookStore) UpdateWebhookStatus(ctx context.Context, id int64, status string) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE webhooks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
status, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update webhook status: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("webhook not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementConsecutiveFailures atomically increments the consecutive failure count
|
||||
// and returns the new value.
|
||||
func (s *SQLiteWebhookStore) IncrementConsecutiveFailures(ctx context.Context, id int64) (int, error) {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE webhooks SET consecutive_failures = consecutive_failures + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("increment consecutive failures: %w", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT consecutive_failures FROM webhooks WHERE id = ?`, id,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get consecutive failures: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ResetConsecutiveFailures resets the consecutive failure count to zero.
|
||||
func (s *SQLiteWebhookStore) ResetConsecutiveFailures(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE webhooks SET consecutive_failures = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset consecutive failures: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebhook deletes a webhook only if it is owned by the specified agent.
|
||||
func (s *SQLiteWebhookStore) DeleteWebhook(ctx context.Context, id int64, agentName string) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM webhooks WHERE id = ? AND agent_name = ?`,
|
||||
id, agentName,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete webhook: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("webhook not found or not owned by agent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountWebhooksByAgent returns the number of webhooks for an agent.
|
||||
func (s *SQLiteWebhookStore) CountWebhooksByAgent(ctx context.Context, agentName string) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM webhooks WHERE agent_name = ?`, agentName,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count webhooks by agent: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// InsertDelivery inserts a new webhook delivery record and returns its ID.
|
||||
func (s *SQLiteWebhookStore) InsertDelivery(ctx context.Context, delivery *WebhookDelivery) (int64, error) {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO webhook_deliveries (webhook_id, agent_name, event, message_id, payload, status, http_status, attempts, max_attempts, last_error, next_retry_at, depth, created_at, delivered_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`,
|
||||
delivery.WebhookID, delivery.AgentName, delivery.Event, delivery.MessageID,
|
||||
delivery.Payload, delivery.Status, nullIntIfZero(delivery.HTTPStatus),
|
||||
delivery.Attempts, delivery.MaxAttempts, nullStringIfEmpty(delivery.LastError),
|
||||
nullTimePtr(delivery.NextRetryAt), delivery.Depth, nullTimePtr(delivery.DeliveredAt),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert delivery: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get delivery id: %w", err)
|
||||
}
|
||||
delivery.ID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateDeliveryStatus updates the status and related fields of a delivery.
|
||||
func (s *SQLiteWebhookStore) UpdateDeliveryStatus(ctx context.Context, id int64, status string, httpStatus int, lastError string, nextRetryAt *time.Time, deliveredAt *time.Time) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE webhook_deliveries
|
||||
SET status = ?, http_status = ?, last_error = ?, next_retry_at = ?, delivered_at = ?
|
||||
WHERE id = ?`,
|
||||
status, nullIntIfZero(httpStatus), nullStringIfEmpty(lastError),
|
||||
nullTimePtr(nextRetryAt), nullTimePtr(deliveredAt), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update delivery status: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("delivery not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDeliveryAttempts updates the attempt count for a delivery.
|
||||
func (s *SQLiteWebhookStore) UpdateDeliveryAttempts(ctx context.Context, id int64, attempts int) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE webhook_deliveries SET attempts = ? WHERE id = ?`,
|
||||
attempts, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update delivery attempts: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("delivery not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPendingDeliveries retrieves pending deliveries up to the specified limit.
|
||||
func (s *SQLiteWebhookStore) GetPendingDeliveries(ctx context.Context, limit int) ([]*WebhookDelivery, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, webhook_id, agent_name, event, message_id, payload, status, http_status, attempts, max_attempts, last_error, next_retry_at, depth, created_at, delivered_at
|
||||
FROM webhook_deliveries WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?`, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get pending deliveries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanDeliveries(rows)
|
||||
}
|
||||
|
||||
// GetRetryableDeliveries retrieves deliveries that are due for retry.
|
||||
func (s *SQLiteWebhookStore) GetRetryableDeliveries(ctx context.Context, now time.Time, limit int) ([]*WebhookDelivery, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, webhook_id, agent_name, event, message_id, payload, status, http_status, attempts, max_attempts, last_error, next_retry_at, depth, created_at, delivered_at
|
||||
FROM webhook_deliveries WHERE status = 'retrying' AND next_retry_at <= ?
|
||||
ORDER BY next_retry_at ASC
|
||||
LIMIT ?`, now, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get retryable deliveries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanDeliveries(rows)
|
||||
}
|
||||
|
||||
// GetDeliveriesByAgent retrieves deliveries for an agent filtered by status.
|
||||
func (s *SQLiteWebhookStore) GetDeliveriesByAgent(ctx context.Context, agentName string, status string, limit int) ([]*WebhookDelivery, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if status != "" {
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT id, webhook_id, agent_name, event, message_id, payload, status, http_status, attempts, max_attempts, last_error, next_retry_at, depth, created_at, delivered_at
|
||||
FROM webhook_deliveries WHERE agent_name = ? AND status = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`, agentName, status, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT id, webhook_id, agent_name, event, message_id, payload, status, http_status, attempts, max_attempts, last_error, next_retry_at, depth, created_at, delivered_at
|
||||
FROM webhook_deliveries WHERE agent_name = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`, agentName, limit,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get deliveries by agent: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanDeliveries(rows)
|
||||
}
|
||||
|
||||
// GetDeliveryByID retrieves a single delivery by its ID.
|
||||
func (s *SQLiteWebhookStore) GetDeliveryByID(ctx context.Context, id int64) (*WebhookDelivery, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, webhook_id, agent_name, event, message_id, payload, status, http_status, attempts, max_attempts, last_error, next_retry_at, depth, created_at, delivered_at
|
||||
FROM webhook_deliveries WHERE id = ?`, id,
|
||||
)
|
||||
return scanDelivery(row)
|
||||
}
|
||||
|
||||
// PurgeOldDeadLetters deletes dead-lettered deliveries older than the given time
|
||||
// and returns the number of rows deleted.
|
||||
func (s *SQLiteWebhookStore) PurgeOldDeadLetters(ctx context.Context, olderThan time.Time) (int64, error) {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM webhook_deliveries WHERE status = 'dead_lettered' AND created_at < ?`,
|
||||
olderThan,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge old dead letters: %w", err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// scanWebhook scans a single webhook from sql.Row.
|
||||
func scanWebhook(row *sql.Row) (*Webhook, error) {
|
||||
var wh Webhook
|
||||
var eventsJSON string
|
||||
|
||||
err := row.Scan(
|
||||
&wh.ID, &wh.AgentName, &wh.URL, &eventsJSON, &wh.SecretHash,
|
||||
&wh.Status, &wh.ConsecutiveFailures, &wh.CreatedAt, &wh.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(eventsJSON), &wh.Events); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal webhook events: %w", err)
|
||||
}
|
||||
if wh.Events == nil {
|
||||
wh.Events = []string{}
|
||||
}
|
||||
return &wh, nil
|
||||
}
|
||||
|
||||
// scanWebhookFromRows scans a single webhook from sql.Rows.
|
||||
func scanWebhookFromRows(rows *sql.Rows) (*Webhook, error) {
|
||||
var wh Webhook
|
||||
var eventsJSON string
|
||||
|
||||
err := rows.Scan(
|
||||
&wh.ID, &wh.AgentName, &wh.URL, &eventsJSON, &wh.SecretHash,
|
||||
&wh.Status, &wh.ConsecutiveFailures, &wh.CreatedAt, &wh.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan webhook: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(eventsJSON), &wh.Events); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal webhook events: %w", err)
|
||||
}
|
||||
if wh.Events == nil {
|
||||
wh.Events = []string{}
|
||||
}
|
||||
return &wh, nil
|
||||
}
|
||||
|
||||
// scanWebhooks scans multiple webhook rows.
|
||||
func scanWebhooks(rows *sql.Rows) ([]*Webhook, error) {
|
||||
var webhooks []*Webhook
|
||||
for rows.Next() {
|
||||
wh, err := scanWebhookFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webhooks = append(webhooks, wh)
|
||||
}
|
||||
if webhooks == nil {
|
||||
webhooks = []*Webhook{}
|
||||
}
|
||||
return webhooks, rows.Err()
|
||||
}
|
||||
|
||||
// scanDelivery scans a single delivery from sql.Row.
|
||||
func scanDelivery(row *sql.Row) (*WebhookDelivery, error) {
|
||||
var d WebhookDelivery
|
||||
var httpStatus sql.NullInt64
|
||||
var lastError sql.NullString
|
||||
var nextRetryAt sql.NullTime
|
||||
var deliveredAt sql.NullTime
|
||||
|
||||
err := row.Scan(
|
||||
&d.ID, &d.WebhookID, &d.AgentName, &d.Event, &d.MessageID,
|
||||
&d.Payload, &d.Status, &httpStatus, &d.Attempts, &d.MaxAttempts,
|
||||
&lastError, &nextRetryAt, &d.Depth, &d.CreatedAt, &deliveredAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if httpStatus.Valid {
|
||||
d.HTTPStatus = int(httpStatus.Int64)
|
||||
}
|
||||
if lastError.Valid {
|
||||
d.LastError = lastError.String
|
||||
}
|
||||
if nextRetryAt.Valid {
|
||||
d.NextRetryAt = &nextRetryAt.Time
|
||||
}
|
||||
if deliveredAt.Valid {
|
||||
d.DeliveredAt = &deliveredAt.Time
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// scanDeliveryFromRows scans a single delivery from sql.Rows.
|
||||
func scanDeliveryFromRows(rows *sql.Rows) (*WebhookDelivery, error) {
|
||||
var d WebhookDelivery
|
||||
var httpStatus sql.NullInt64
|
||||
var lastError sql.NullString
|
||||
var nextRetryAt sql.NullTime
|
||||
var deliveredAt sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
&d.ID, &d.WebhookID, &d.AgentName, &d.Event, &d.MessageID,
|
||||
&d.Payload, &d.Status, &httpStatus, &d.Attempts, &d.MaxAttempts,
|
||||
&lastError, &nextRetryAt, &d.Depth, &d.CreatedAt, &deliveredAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan delivery: %w", err)
|
||||
}
|
||||
|
||||
if httpStatus.Valid {
|
||||
d.HTTPStatus = int(httpStatus.Int64)
|
||||
}
|
||||
if lastError.Valid {
|
||||
d.LastError = lastError.String
|
||||
}
|
||||
if nextRetryAt.Valid {
|
||||
d.NextRetryAt = &nextRetryAt.Time
|
||||
}
|
||||
if deliveredAt.Valid {
|
||||
d.DeliveredAt = &deliveredAt.Time
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// scanDeliveries scans multiple delivery rows.
|
||||
func scanDeliveries(rows *sql.Rows) ([]*WebhookDelivery, error) {
|
||||
var deliveries []*WebhookDelivery
|
||||
for rows.Next() {
|
||||
d, err := scanDeliveryFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deliveries = append(deliveries, d)
|
||||
}
|
||||
if deliveries == nil {
|
||||
deliveries = []*WebhookDelivery{}
|
||||
}
|
||||
return deliveries, rows.Err()
|
||||
}
|
||||
|
||||
// nullIntIfZero returns sql.NullInt64 that is null when value is 0.
|
||||
func nullIntIfZero(v int) sql.NullInt64 {
|
||||
if v == 0 {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: int64(v), Valid: true}
|
||||
}
|
||||
|
||||
// nullStringIfEmpty returns sql.NullString that is null when value is empty.
|
||||
func nullStringIfEmpty(v string) sql.NullString {
|
||||
if v == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: v, Valid: true}
|
||||
}
|
||||
|
||||
// nullTimePtr returns sql.NullTime from a *time.Time, null when pointer is nil.
|
||||
func nullTimePtr(t *time.Time) sql.NullTime {
|
||||
if t == nil {
|
||||
return sql.NullTime{}
|
||||
}
|
||||
return sql.NullTime{Time: *t, Valid: true}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := storage.RunMigrations(context.Background(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// seedAgent inserts prerequisite user and agent rows so that foreign key
|
||||
// constraints on webhooks (agent_name -> agents.name) are satisfied.
|
||||
func seedAgent(t *testing.T, db *sql.DB, agentName string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
// Insert a user (owner)
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO users (username, password_hash, display_name) VALUES (?, 'hash', 'Test User')`,
|
||||
"owner-"+agentName,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
var ownerID int64
|
||||
err = db.QueryRowContext(ctx, `SELECT id FROM users WHERE username = ?`, "owner-"+agentName).Scan(&ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("get owner id: %v", err)
|
||||
}
|
||||
_, err = db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', ?, 'hash', 'active')`,
|
||||
agentName, agentName, ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestWebhook(agentName, url string, events []string) *Webhook {
|
||||
return &Webhook{
|
||||
AgentName: agentName,
|
||||
URL: url,
|
||||
Events: events,
|
||||
SecretHash: "testhash",
|
||||
Status: WebhookStatusActive,
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertWebhook(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-a")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
webhook *Webhook
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "insert valid webhook",
|
||||
webhook: makeTestWebhook("agent-a", "https://example.com/hook1", []string{"message.received"}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "insert with multiple events",
|
||||
webhook: makeTestWebhook("agent-a", "https://example.com/hook2", []string{"message.received", "message.mentioned"}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate url for same agent fails",
|
||||
webhook: makeTestWebhook("agent-a", "https://example.com/hook1", []string{"message.received"}),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id, err := store.InsertWebhook(ctx, tt.webhook)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("InsertWebhook() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && id <= 0 {
|
||||
t.Errorf("expected positive ID, got %d", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWebhookByID(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-b")
|
||||
|
||||
wh := makeTestWebhook("agent-b", "https://example.com/hook", []string{"message.received", "channel.message"})
|
||||
id, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id int64
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "existing webhook", id: id, wantErr: false},
|
||||
{name: "non-existent webhook", id: 9999, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := store.GetWebhookByID(ctx, tt.id)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("GetWebhookByID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if got.ID != id {
|
||||
t.Errorf("ID = %d, want %d", got.ID, id)
|
||||
}
|
||||
if got.AgentName != "agent-b" {
|
||||
t.Errorf("AgentName = %q, want %q", got.AgentName, "agent-b")
|
||||
}
|
||||
if got.URL != "https://example.com/hook" {
|
||||
t.Errorf("URL = %q, want %q", got.URL, "https://example.com/hook")
|
||||
}
|
||||
if len(got.Events) != 2 {
|
||||
t.Errorf("Events length = %d, want 2", len(got.Events))
|
||||
}
|
||||
if got.Status != WebhookStatusActive {
|
||||
t.Errorf("Status = %q, want %q", got.Status, WebhookStatusActive)
|
||||
}
|
||||
if got.ConsecutiveFailures != 0 {
|
||||
t.Errorf("ConsecutiveFailures = %d, want 0", got.ConsecutiveFailures)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWebhooksByAgent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-c")
|
||||
seedAgent(t, db, "agent-d")
|
||||
|
||||
// Insert webhooks for agent-c
|
||||
for i, url := range []string{"https://example.com/c1", "https://example.com/c2"} {
|
||||
wh := makeTestWebhook("agent-c", url, []string{"message.received"})
|
||||
if _, err := store.InsertWebhook(ctx, wh); err != nil {
|
||||
t.Fatalf("insert webhook %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Insert webhook for agent-d
|
||||
wh := makeTestWebhook("agent-d", "https://example.com/d1", []string{"message.received"})
|
||||
if _, err := store.InsertWebhook(ctx, wh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentName string
|
||||
wantCount int
|
||||
}{
|
||||
{name: "agent with two webhooks", agentName: "agent-c", wantCount: 2},
|
||||
{name: "agent with one webhook", agentName: "agent-d", wantCount: 1},
|
||||
{name: "agent with no webhooks", agentName: "nonexistent", wantCount: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
webhooks, err := store.GetWebhooksByAgent(ctx, tt.agentName)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWebhooksByAgent() error = %v", err)
|
||||
}
|
||||
if len(webhooks) != tt.wantCount {
|
||||
t.Errorf("got %d webhooks, want %d", len(webhooks), tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveWebhooksByEvent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-e")
|
||||
|
||||
// Webhook subscribed to message.received only
|
||||
wh1 := makeTestWebhook("agent-e", "https://example.com/e1", []string{"message.received"})
|
||||
if _, err := store.InsertWebhook(ctx, wh1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Webhook subscribed to both events
|
||||
wh2 := makeTestWebhook("agent-e", "https://example.com/e2", []string{"message.received", "message.mentioned"})
|
||||
if _, err := store.InsertWebhook(ctx, wh2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Disabled webhook subscribed to message.received
|
||||
wh3 := makeTestWebhook("agent-e", "https://example.com/e3", []string{"message.received"})
|
||||
wh3.Status = WebhookStatusDisabled
|
||||
if _, err := store.InsertWebhook(ctx, wh3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentName string
|
||||
event string
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "message.received matches two active webhooks",
|
||||
agentName: "agent-e",
|
||||
event: "message.received",
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
name: "message.mentioned matches one active webhook",
|
||||
agentName: "agent-e",
|
||||
event: "message.mentioned",
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "channel.message matches none",
|
||||
agentName: "agent-e",
|
||||
event: "channel.message",
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "non-existent agent matches none",
|
||||
agentName: "no-agent",
|
||||
event: "message.received",
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
webhooks, err := store.GetActiveWebhooksByEvent(ctx, tt.agentName, tt.event)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveWebhooksByEvent() error = %v", err)
|
||||
}
|
||||
if len(webhooks) != tt.wantCount {
|
||||
t.Errorf("got %d webhooks, want %d", len(webhooks), tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWebhookStatus(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-f")
|
||||
|
||||
wh := makeTestWebhook("agent-f", "https://example.com/f1", []string{"message.received"})
|
||||
id, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id int64
|
||||
newStatus string
|
||||
wantErr bool
|
||||
wantStatus string
|
||||
}{
|
||||
{name: "disable webhook", id: id, newStatus: WebhookStatusDisabled, wantErr: false, wantStatus: WebhookStatusDisabled},
|
||||
{name: "re-enable webhook", id: id, newStatus: WebhookStatusActive, wantErr: false, wantStatus: WebhookStatusActive},
|
||||
{name: "non-existent webhook", id: 9999, newStatus: WebhookStatusDisabled, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := store.UpdateWebhookStatus(ctx, tt.id, tt.newStatus)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("UpdateWebhookStatus() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr {
|
||||
got, err := store.GetWebhookByID(ctx, tt.id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != tt.wantStatus {
|
||||
t.Errorf("status = %q, want %q", got.Status, tt.wantStatus)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementConsecutiveFailures(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-g")
|
||||
|
||||
wh := makeTestWebhook("agent-g", "https://example.com/g1", []string{"message.received"})
|
||||
id, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Increment three times
|
||||
for i := 1; i <= 3; i++ {
|
||||
count, err := store.IncrementConsecutiveFailures(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("increment %d: %v", i, err)
|
||||
}
|
||||
if count != i {
|
||||
t.Errorf("after increment %d: count = %d, want %d", i, count, i)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify via GetWebhookByID
|
||||
got, err := store.GetWebhookByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ConsecutiveFailures != 3 {
|
||||
t.Errorf("ConsecutiveFailures = %d, want 3", got.ConsecutiveFailures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConsecutiveFailures(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-h")
|
||||
|
||||
wh := makeTestWebhook("agent-h", "https://example.com/h1", []string{"message.received"})
|
||||
id, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Increment a few times
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := store.IncrementConsecutiveFailures(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset
|
||||
if err := store.ResetConsecutiveFailures(ctx, id); err != nil {
|
||||
t.Fatalf("ResetConsecutiveFailures() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetWebhookByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ConsecutiveFailures != 0 {
|
||||
t.Errorf("ConsecutiveFailures = %d, want 0", got.ConsecutiveFailures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWebhook(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-i")
|
||||
seedAgent(t, db, "agent-j")
|
||||
|
||||
wh := makeTestWebhook("agent-i", "https://example.com/i1", []string{"message.received"})
|
||||
id, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id int64
|
||||
agentName string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "wrong owner fails", id: id, agentName: "agent-j", wantErr: true},
|
||||
{name: "correct owner succeeds", id: id, agentName: "agent-i", wantErr: false},
|
||||
{name: "already deleted fails", id: id, agentName: "agent-i", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := store.DeleteWebhook(ctx, tt.id, tt.agentName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("DeleteWebhook() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountWebhooksByAgent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-k")
|
||||
|
||||
count, err := store.CountWebhooksByAgent(ctx, "agent-k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("initial count = %d, want 0", count)
|
||||
}
|
||||
|
||||
for i, url := range []string{"https://example.com/k1", "https://example.com/k2"} {
|
||||
wh := makeTestWebhook("agent-k", url, []string{"message.received"})
|
||||
if _, err := store.InsertWebhook(ctx, wh); err != nil {
|
||||
t.Fatalf("insert %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err = store.CountWebhooksByAgent(ctx, "agent-k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertDelivery(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-l")
|
||||
|
||||
wh := makeTestWebhook("agent-l", "https://example.com/l1", []string{"message.received"})
|
||||
whID, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
delivery := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-l",
|
||||
Event: "message.received",
|
||||
MessageID: 100,
|
||||
Payload: `{"test":"payload"}`,
|
||||
Status: DeliveryStatusPending,
|
||||
MaxAttempts: 3,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
id, err := store.InsertDelivery(ctx, delivery)
|
||||
if err != nil {
|
||||
t.Fatalf("InsertDelivery() error = %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Errorf("expected positive ID, got %d", id)
|
||||
}
|
||||
if delivery.ID != id {
|
||||
t.Errorf("delivery.ID = %d, want %d (should be set by InsertDelivery)", delivery.ID, id)
|
||||
}
|
||||
|
||||
// Verify via GetDeliveryByID
|
||||
got, err := store.GetDeliveryByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.WebhookID != whID {
|
||||
t.Errorf("WebhookID = %d, want %d", got.WebhookID, whID)
|
||||
}
|
||||
if got.Status != DeliveryStatusPending {
|
||||
t.Errorf("Status = %q, want %q", got.Status, DeliveryStatusPending)
|
||||
}
|
||||
if got.Payload != `{"test":"payload"}` {
|
||||
t.Errorf("Payload = %q, want %q", got.Payload, `{"test":"payload"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeliveryStatus(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-m")
|
||||
|
||||
wh := makeTestWebhook("agent-m", "https://example.com/m1", []string{"message.received"})
|
||||
whID, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
delivery := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-m",
|
||||
Event: "message.received",
|
||||
MessageID: 101,
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusPending,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
id, err := store.InsertDelivery(ctx, delivery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Mark as delivered
|
||||
now := time.Now()
|
||||
err = store.UpdateDeliveryStatus(ctx, id, DeliveryStatusDelivered, 200, "", nil, &now)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDeliveryStatus() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetDeliveryByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != DeliveryStatusDelivered {
|
||||
t.Errorf("Status = %q, want %q", got.Status, DeliveryStatusDelivered)
|
||||
}
|
||||
if got.HTTPStatus != 200 {
|
||||
t.Errorf("HTTPStatus = %d, want 200", got.HTTPStatus)
|
||||
}
|
||||
if got.DeliveredAt == nil {
|
||||
t.Error("DeliveredAt should not be nil")
|
||||
}
|
||||
|
||||
// Mark as retrying with error and next_retry_at
|
||||
nextRetry := time.Now().Add(5 * time.Minute)
|
||||
err = store.UpdateDeliveryStatus(ctx, id, DeliveryStatusRetrying, 500, "server error", &nextRetry, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDeliveryStatus(retrying) error = %v", err)
|
||||
}
|
||||
|
||||
got, err = store.GetDeliveryByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != DeliveryStatusRetrying {
|
||||
t.Errorf("Status = %q, want %q", got.Status, DeliveryStatusRetrying)
|
||||
}
|
||||
if got.LastError != "server error" {
|
||||
t.Errorf("LastError = %q, want %q", got.LastError, "server error")
|
||||
}
|
||||
if got.NextRetryAt == nil {
|
||||
t.Error("NextRetryAt should not be nil")
|
||||
}
|
||||
|
||||
// Non-existent delivery
|
||||
err = store.UpdateDeliveryStatus(ctx, 9999, DeliveryStatusDelivered, 200, "", nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent delivery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingDeliveries(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-n")
|
||||
|
||||
wh := makeTestWebhook("agent-n", "https://example.com/n1", []string{"message.received"})
|
||||
whID, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert 3 pending deliveries
|
||||
for i := 0; i < 3; i++ {
|
||||
d := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-n",
|
||||
Event: "message.received",
|
||||
MessageID: int64(200 + i),
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusPending,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert a delivered one (should not appear)
|
||||
delivered := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-n",
|
||||
Event: "message.received",
|
||||
MessageID: 299,
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusDelivered,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, delivered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pending, err := store.GetPendingDeliveries(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 3 {
|
||||
t.Errorf("got %d pending, want 3", len(pending))
|
||||
}
|
||||
for _, d := range pending {
|
||||
if d.Status != DeliveryStatusPending {
|
||||
t.Errorf("delivery %d has status %q, want %q", d.ID, d.Status, DeliveryStatusPending)
|
||||
}
|
||||
}
|
||||
|
||||
// Test limit
|
||||
limited, err := store.GetPendingDeliveries(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(limited) != 2 {
|
||||
t.Errorf("got %d with limit 2, want 2", len(limited))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRetryableDeliveries(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-o")
|
||||
|
||||
wh := makeTestWebhook("agent-o", "https://example.com/o1", []string{"message.received"})
|
||||
whID, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
past := now.Add(-10 * time.Minute)
|
||||
future := now.Add(10 * time.Minute)
|
||||
|
||||
// Retrying delivery with past next_retry_at (should be returned)
|
||||
d1 := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-o",
|
||||
Event: "message.received",
|
||||
MessageID: 300,
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusRetrying,
|
||||
MaxAttempts: 3,
|
||||
NextRetryAt: &past,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, d1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Retrying delivery with future next_retry_at (should NOT be returned)
|
||||
d2 := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-o",
|
||||
Event: "message.received",
|
||||
MessageID: 301,
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusRetrying,
|
||||
MaxAttempts: 3,
|
||||
NextRetryAt: &future,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, d2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Pending delivery (should NOT be returned)
|
||||
d3 := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-o",
|
||||
Event: "message.received",
|
||||
MessageID: 302,
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusPending,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, d3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
retryable, err := store.GetRetryableDeliveries(ctx, now, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(retryable) != 1 {
|
||||
t.Fatalf("got %d retryable, want 1", len(retryable))
|
||||
}
|
||||
if retryable[0].MessageID != 300 {
|
||||
t.Errorf("got message_id %d, want 300", retryable[0].MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeliveriesByAgent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-p")
|
||||
seedAgent(t, db, "agent-q")
|
||||
|
||||
whP := makeTestWebhook("agent-p", "https://example.com/p1", []string{"message.received"})
|
||||
whPID, err := store.InsertWebhook(ctx, whP)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
whQ := makeTestWebhook("agent-q", "https://example.com/q1", []string{"message.received"})
|
||||
whQID, err := store.InsertWebhook(ctx, whQ)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Deliveries for agent-p
|
||||
for _, status := range []string{DeliveryStatusPending, DeliveryStatusDelivered, DeliveryStatusPending} {
|
||||
d := &WebhookDelivery{
|
||||
WebhookID: whPID,
|
||||
AgentName: "agent-p",
|
||||
Event: "message.received",
|
||||
MessageID: 400,
|
||||
Payload: `{}`,
|
||||
Status: status,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Delivery for agent-q
|
||||
dq := &WebhookDelivery{
|
||||
WebhookID: whQID,
|
||||
AgentName: "agent-q",
|
||||
Event: "message.received",
|
||||
MessageID: 401,
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusPending,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, dq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentName string
|
||||
status string
|
||||
wantCount int
|
||||
}{
|
||||
{name: "all for agent-p", agentName: "agent-p", status: "", wantCount: 3},
|
||||
{name: "pending for agent-p", agentName: "agent-p", status: DeliveryStatusPending, wantCount: 2},
|
||||
{name: "delivered for agent-p", agentName: "agent-p", status: DeliveryStatusDelivered, wantCount: 1},
|
||||
{name: "all for agent-q", agentName: "agent-q", status: "", wantCount: 1},
|
||||
{name: "nonexistent agent", agentName: "nope", status: "", wantCount: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
deliveries, err := store.GetDeliveriesByAgent(ctx, tt.agentName, tt.status, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeliveriesByAgent() error = %v", err)
|
||||
}
|
||||
if len(deliveries) != tt.wantCount {
|
||||
t.Errorf("got %d, want %d", len(deliveries), tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeOldDeadLetters(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteWebhookStore(db)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, db, "agent-r")
|
||||
|
||||
wh := makeTestWebhook("agent-r", "https://example.com/r1", []string{"message.received"})
|
||||
whID, err := store.InsertWebhook(ctx, wh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert dead-lettered deliveries
|
||||
for i := 0; i < 3; i++ {
|
||||
d := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-r",
|
||||
Event: "message.received",
|
||||
MessageID: int64(500 + i),
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusDeadLettered,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert a pending delivery (should NOT be purged)
|
||||
pending := &WebhookDelivery{
|
||||
WebhookID: whID,
|
||||
AgentName: "agent-r",
|
||||
Event: "message.received",
|
||||
MessageID: 599,
|
||||
Payload: `{}`,
|
||||
Status: DeliveryStatusPending,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if _, err := store.InsertDelivery(ctx, pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Purge with a future cutoff (should purge all dead-lettered)
|
||||
cutoff := time.Now().Add(1 * time.Hour)
|
||||
purged, err := store.PurgeOldDeadLetters(ctx, cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeOldDeadLetters() error = %v", err)
|
||||
}
|
||||
if purged != 3 {
|
||||
t.Errorf("purged %d, want 3", purged)
|
||||
}
|
||||
|
||||
// Verify pending delivery still exists
|
||||
all, err := store.GetDeliveriesByAgent(ctx, "agent-r", "", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Errorf("remaining deliveries = %d, want 1", len(all))
|
||||
}
|
||||
if all[0].Status != DeliveryStatusPending {
|
||||
t.Errorf("remaining delivery status = %q, want %q", all[0].Status, DeliveryStatusPending)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Webhook registrations
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL REFERENCES agents(name) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
secret_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(agent_name, url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_agent_name ON webhooks(agent_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_agent_status ON webhooks(agent_name, status);
|
||||
|
||||
-- Webhook delivery tracking
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
|
||||
agent_name TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'retrying', 'dead_lettered')),
|
||||
http_status INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
last_error TEXT,
|
||||
next_retry_at DATETIME,
|
||||
depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
delivered_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_webhook_id ON webhook_deliveries(webhook_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_status ON webhook_deliveries(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_agent_status ON webhook_deliveries(agent_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_next_retry ON webhook_deliveries(next_retry_at) WHERE status = 'retrying';
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_created_at ON webhook_deliveries(created_at);
|
||||
|
||||
-- K8s handler registrations
|
||||
CREATE TABLE IF NOT EXISTS k8s_handlers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL REFERENCES agents(name) ON DELETE CASCADE,
|
||||
image TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
namespace TEXT NOT NULL,
|
||||
resources_memory TEXT NOT NULL DEFAULT '256Mi',
|
||||
resources_cpu TEXT NOT NULL DEFAULT '100m',
|
||||
env TEXT NOT NULL DEFAULT '{}',
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 600 CHECK(timeout_seconds >= 60 AND timeout_seconds <= 3600),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(agent_name, image, namespace)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_k8s_handlers_agent ON k8s_handlers(agent_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_k8s_handlers_agent_status ON k8s_handlers(agent_name, status);
|
||||
|
||||
-- K8s job run tracking
|
||||
CREATE TABLE IF NOT EXISTS k8s_job_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
handler_id INTEGER NOT NULL REFERENCES k8s_handlers(id) ON DELETE CASCADE,
|
||||
agent_name TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
job_name TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'succeeded', 'failed')),
|
||||
failure_reason TEXT,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_handler ON k8s_job_runs(handler_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_agent_status ON k8s_job_runs(agent_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_job_name ON k8s_job_runs(job_name);
|
||||
@@ -44,6 +44,6 @@
|
||||
|
||||
## R8: Docker Image Registry
|
||||
|
||||
**Decision**: ghcr.io/smart-mcp-proxy/synapbus
|
||||
**Decision**: ghcr.io/synapbus/synapbus
|
||||
**Rationale**: GitHub Container Registry is free for public repos, integrates with GitHub Actions natively (GITHUB_TOKEN auth).
|
||||
**Alternatives considered**: Docker Hub (rate limits), ECR (AWS-specific)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: Webhooks & Kubernetes Job Runner
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-14
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.plan`.
|
||||
- Constitution compliance section added and verified against all 10 principles.
|
||||
- 10 assumptions documented to resolve ambiguities with secure defaults.
|
||||
- K8s client-go noted as pure Go dependency (compliant with Principle III).
|
||||
@@ -0,0 +1,218 @@
|
||||
# MCP Tool Contracts: Webhooks & K8s Job Runner
|
||||
|
||||
## Webhook Tools
|
||||
|
||||
### register_webhook
|
||||
|
||||
Registers a webhook URL for event-driven message delivery.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"url": { "type": "string", "required": true, "description": "HTTPS URL for webhook delivery" },
|
||||
"events": { "type": "array", "items": "string", "required": true, "description": "Events to subscribe to: message.received, message.mentioned, channel.message" },
|
||||
"secret": { "type": "string", "required": false, "description": "HMAC signing secret (min 32 chars, auto-generated if omitted)" }
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response** (shown once):
|
||||
```json
|
||||
{
|
||||
"webhook_id": 1,
|
||||
"url": "https://agent.example.com/webhook",
|
||||
"events": ["message.received"],
|
||||
"secret": "auto-generated-secret-shown-once-min-32-chars",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- `"maximum 3 webhooks per agent reached"` — agent already has 3 webhooks
|
||||
- `"webhook URL must use HTTPS"` — HTTP URL in production mode
|
||||
- `"webhook URL resolves to a private network address"` — SSRF protection
|
||||
- `"secret must be at least 32 characters"` — secret too short
|
||||
- `"invalid event type: X; valid types: message.received, message.mentioned, channel.message"` — bad event
|
||||
|
||||
---
|
||||
|
||||
### list_webhooks
|
||||
|
||||
Lists all webhooks registered by the calling agent.
|
||||
|
||||
**Parameters**: None
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"webhooks": [
|
||||
{
|
||||
"id": 1,
|
||||
"url": "https://agent.example.com/***",
|
||||
"events": ["message.received"],
|
||||
"status": "active",
|
||||
"consecutive_failures": 0,
|
||||
"created_at": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### delete_webhook
|
||||
|
||||
Removes a webhook registration. Only the owning agent can delete.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"webhook_id": { "type": "integer", "required": true, "description": "ID of the webhook to delete" }
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response**:
|
||||
```json
|
||||
{ "deleted": true, "webhook_id": 1 }
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- `"webhook not found or not owned by this agent"` — invalid ID or wrong agent
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes Handler Tools
|
||||
|
||||
### register_k8s_handler
|
||||
|
||||
Registers a K8s Job handler for event-driven container execution.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"image": { "type": "string", "required": true, "description": "Container image (e.g., 'my-agent:latest')" },
|
||||
"events": { "type": "array", "items": "string", "required": true, "description": "Events to subscribe to" },
|
||||
"namespace": { "type": "string", "required": false, "description": "K8s namespace (defaults to SynapBus namespace)" },
|
||||
"resources": { "type": "object", "required": false, "description": "Resource limits: {memory: '512Mi', cpu: '250m'}" },
|
||||
"env": { "type": "object", "required": false, "description": "Environment variables as key-value pairs" },
|
||||
"timeout_seconds": { "type": "integer", "required": false, "description": "Job timeout (60-3600, default 600)" }
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response**:
|
||||
```json
|
||||
{
|
||||
"handler_id": 1,
|
||||
"image": "my-agent:latest",
|
||||
"events": ["message.received"],
|
||||
"namespace": "default",
|
||||
"resources": { "memory": "512Mi", "cpu": "250m" },
|
||||
"timeout_seconds": 600,
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- `"Kubernetes job runner is not available (not running in-cluster)"` — not in K8s
|
||||
- `"maximum 3 K8s handlers per agent reached"` — handler limit
|
||||
- `"timeout_seconds must be between 60 and 3600"` — invalid timeout
|
||||
|
||||
---
|
||||
|
||||
### list_k8s_handlers
|
||||
|
||||
Lists all K8s handlers registered by the calling agent.
|
||||
|
||||
**Parameters**: None
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"handlers": [
|
||||
{
|
||||
"id": 1,
|
||||
"image": "my-agent:latest",
|
||||
"events": ["message.received"],
|
||||
"namespace": "ml-jobs",
|
||||
"resources": { "memory": "512Mi", "cpu": "250m" },
|
||||
"timeout_seconds": 600,
|
||||
"status": "active",
|
||||
"created_at": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### delete_k8s_handler
|
||||
|
||||
Removes a K8s handler registration.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"handler_id": { "type": "integer", "required": true, "description": "ID of the handler to delete" }
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response**:
|
||||
```json
|
||||
{ "deleted": true, "handler_id": 1 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Delivery Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message.received",
|
||||
"delivery_id": "d-abc123",
|
||||
"message": {
|
||||
"id": 42,
|
||||
"from": "alice-bot",
|
||||
"body": "Hello, please process this data",
|
||||
"channel": "general",
|
||||
"priority": 5,
|
||||
"metadata": {}
|
||||
},
|
||||
"agent": "processor-bot",
|
||||
"timestamp": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Headers**:
|
||||
```
|
||||
Content-Type: application/json
|
||||
X-SynapBus-Signature: sha256=<HMAC-SHA256 hex digest of body>
|
||||
X-SynapBus-Event: message.received
|
||||
X-SynapBus-Delivery: d-abc123
|
||||
X-SynapBus-Depth: 0
|
||||
X-SynapBus-Timestamp: 1710417600
|
||||
```
|
||||
|
||||
## REST API Contracts (Web UI)
|
||||
|
||||
### GET /api/webhooks?agent={name}
|
||||
List webhooks for an agent (owner authenticated).
|
||||
|
||||
### POST /api/webhooks/{id}/enable
|
||||
Re-enable a disabled webhook (resets consecutive failures).
|
||||
|
||||
### POST /api/webhooks/{id}/disable
|
||||
Manually disable a webhook.
|
||||
|
||||
### GET /api/webhook-deliveries?agent={name}&status={status}&limit={n}
|
||||
List delivery history with filtering.
|
||||
|
||||
### POST /api/webhook-deliveries/{id}/retry
|
||||
Retry a dead-lettered delivery.
|
||||
|
||||
### GET /api/k8s-handlers?agent={name}
|
||||
List K8s handlers for an agent.
|
||||
|
||||
### GET /api/k8s-job-runs?agent={name}&status={status}&limit={n}
|
||||
List K8s job runs with filtering.
|
||||
|
||||
### GET /api/k8s-job-runs/{id}/logs
|
||||
Fetch logs for a K8s job run (proxied from K8s API).
|
||||
@@ -0,0 +1,233 @@
|
||||
# Data Model: Webhooks & Kubernetes Job Runner
|
||||
|
||||
**Feature**: 003-webhooks-k8s-runner
|
||||
**Date**: 2026-03-14
|
||||
|
||||
## Entities
|
||||
|
||||
### Webhook
|
||||
|
||||
Registered HTTP endpoint for event-driven delivery to an agent.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique identifier |
|
||||
| agent_name | TEXT | NOT NULL, FK agents(name) | Owning agent |
|
||||
| url | TEXT | NOT NULL | Delivery URL (HTTPS required in prod) |
|
||||
| events | TEXT | NOT NULL | JSON array of subscribed events |
|
||||
| secret_hash | TEXT | NOT NULL | SHA-256 hash of HMAC signing secret |
|
||||
| status | TEXT | NOT NULL DEFAULT 'active' | active, disabled |
|
||||
| consecutive_failures | INTEGER | NOT NULL DEFAULT 0 | Auto-disable at 50 |
|
||||
| created_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Registration time |
|
||||
| updated_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Last modification |
|
||||
|
||||
**Constraints**:
|
||||
- Maximum 3 webhooks per agent (enforced in application layer with COUNT check before insert)
|
||||
- Unique constraint on (agent_name, url) to prevent duplicate registrations
|
||||
- Events values: `message.received`, `message.mentioned`, `channel.message`
|
||||
|
||||
**Indexes**:
|
||||
- `idx_webhooks_agent_name` ON (agent_name) — lookup by agent
|
||||
- `idx_webhooks_agent_status` ON (agent_name, status) — active webhooks per agent
|
||||
|
||||
---
|
||||
|
||||
### WebhookDelivery
|
||||
|
||||
Record of a webhook delivery attempt with full audit trail.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique delivery ID |
|
||||
| webhook_id | INTEGER | NOT NULL, FK webhooks(id) | Parent webhook |
|
||||
| agent_name | TEXT | NOT NULL | Target agent |
|
||||
| event | TEXT | NOT NULL | Event type that triggered delivery |
|
||||
| message_id | INTEGER | NOT NULL | Triggering message ID |
|
||||
| payload | TEXT | NOT NULL | JSON payload sent/to-send |
|
||||
| status | TEXT | NOT NULL DEFAULT 'pending' | pending, delivered, retrying, dead_lettered |
|
||||
| http_status | INTEGER | | Last HTTP response code |
|
||||
| attempts | INTEGER | NOT NULL DEFAULT 0 | Number of delivery attempts |
|
||||
| max_attempts | INTEGER | NOT NULL DEFAULT 3 | Maximum retry attempts |
|
||||
| last_error | TEXT | | Last error message |
|
||||
| next_retry_at | DATETIME | | Scheduled retry time |
|
||||
| depth | INTEGER | NOT NULL DEFAULT 0 | Webhook chain depth |
|
||||
| created_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Creation time |
|
||||
| delivered_at | DATETIME | | Successful delivery time |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_deliveries_webhook_id` ON (webhook_id) — delivery history per webhook
|
||||
- `idx_deliveries_status` ON (status) — find pending/retrying deliveries
|
||||
- `idx_deliveries_agent_status` ON (agent_name, status) — agent's dead letters
|
||||
- `idx_deliveries_next_retry` ON (next_retry_at) WHERE status = 'retrying' — retry queue
|
||||
- `idx_deliveries_created_at` ON (created_at) — purge old dead letters
|
||||
|
||||
**State Transitions**:
|
||||
```
|
||||
pending → delivered (HTTP 2xx)
|
||||
pending → retrying (HTTP 4xx/5xx, attempts < max_attempts)
|
||||
retrying → delivered (HTTP 2xx on retry)
|
||||
retrying → retrying (HTTP 4xx/5xx, attempts < max_attempts)
|
||||
retrying → dead_lettered (attempts >= max_attempts)
|
||||
pending → dead_lettered (depth exceeded, blocked IP, rate exceeded timeout)
|
||||
dead_lettered → pending (manual retry from Web UI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### K8sHandler
|
||||
|
||||
Registered Kubernetes Job template for event-driven processing.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique identifier |
|
||||
| agent_name | TEXT | NOT NULL, FK agents(name) | Owning agent |
|
||||
| image | TEXT | NOT NULL | Container image to run |
|
||||
| events | TEXT | NOT NULL | JSON array of subscribed events |
|
||||
| namespace | TEXT | NOT NULL | K8s namespace for Jobs |
|
||||
| resources_memory | TEXT | NOT NULL DEFAULT '256Mi' | Memory limit |
|
||||
| resources_cpu | TEXT | NOT NULL DEFAULT '100m' | CPU limit |
|
||||
| env | TEXT | NOT NULL DEFAULT '{}' | JSON object of env vars |
|
||||
| timeout_seconds | INTEGER | NOT NULL DEFAULT 600 | Job activeDeadlineSeconds |
|
||||
| status | TEXT | NOT NULL DEFAULT 'active' | active, disabled |
|
||||
| created_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Registration time |
|
||||
| updated_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Last modification |
|
||||
|
||||
**Constraints**:
|
||||
- Maximum 3 K8s handlers per agent (enforced in application layer)
|
||||
- Unique constraint on (agent_name, image, namespace) to prevent duplicates
|
||||
- timeout_seconds: min 60, max 3600
|
||||
|
||||
**Indexes**:
|
||||
- `idx_k8s_handlers_agent` ON (agent_name) — lookup by agent
|
||||
- `idx_k8s_handlers_agent_status` ON (agent_name, status) — active handlers per agent
|
||||
|
||||
---
|
||||
|
||||
### K8sJobRun
|
||||
|
||||
Record of a Kubernetes Job execution triggered by a message event.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique run ID |
|
||||
| handler_id | INTEGER | NOT NULL, FK k8s_handlers(id) | Parent handler |
|
||||
| agent_name | TEXT | NOT NULL | Target agent |
|
||||
| message_id | INTEGER | NOT NULL | Triggering message ID |
|
||||
| job_name | TEXT | NOT NULL | K8s Job name |
|
||||
| namespace | TEXT | NOT NULL | K8s namespace |
|
||||
| status | TEXT | NOT NULL DEFAULT 'pending' | pending, running, succeeded, failed |
|
||||
| failure_reason | TEXT | | Reason for failure |
|
||||
| started_at | DATETIME | | Job start time |
|
||||
| completed_at | DATETIME | | Job completion time |
|
||||
| created_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Record creation |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_job_runs_handler` ON (handler_id) — runs per handler
|
||||
- `idx_job_runs_agent_status` ON (agent_name, status) — agent's active jobs
|
||||
- `idx_job_runs_job_name` ON (job_name) — lookup by K8s Job name (for watcher updates)
|
||||
|
||||
**State Transitions**:
|
||||
```
|
||||
pending → running (Job observed as Active)
|
||||
running → succeeded (Job completed successfully)
|
||||
running → failed (Job failed or deadline exceeded)
|
||||
pending → failed (K8s API error creating Job)
|
||||
```
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
Agent (1) ──────── (0..3) Webhook
|
||||
Webhook (1) ────── (0..*) WebhookDelivery
|
||||
Message (1) ────── (0..*) WebhookDelivery
|
||||
|
||||
Agent (1) ──────── (0..3) K8sHandler
|
||||
K8sHandler (1) ─── (0..*) K8sJobRun
|
||||
Message (1) ────── (0..*) K8sJobRun
|
||||
```
|
||||
|
||||
## Migration: 009_webhooks.sql
|
||||
|
||||
```sql
|
||||
-- Webhook registrations
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL REFERENCES agents(name) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
secret_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(agent_name, url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_agent_name ON webhooks(agent_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_agent_status ON webhooks(agent_name, status);
|
||||
|
||||
-- Webhook delivery tracking
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
|
||||
agent_name TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'retrying', 'dead_lettered')),
|
||||
http_status INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
last_error TEXT,
|
||||
next_retry_at DATETIME,
|
||||
depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
delivered_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_webhook_id ON webhook_deliveries(webhook_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_status ON webhook_deliveries(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_agent_status ON webhook_deliveries(agent_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_next_retry ON webhook_deliveries(next_retry_at) WHERE status = 'retrying';
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_created_at ON webhook_deliveries(created_at);
|
||||
|
||||
-- K8s handler registrations
|
||||
CREATE TABLE IF NOT EXISTS k8s_handlers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL REFERENCES agents(name) ON DELETE CASCADE,
|
||||
image TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
namespace TEXT NOT NULL,
|
||||
resources_memory TEXT NOT NULL DEFAULT '256Mi',
|
||||
resources_cpu TEXT NOT NULL DEFAULT '100m',
|
||||
env TEXT NOT NULL DEFAULT '{}',
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 600 CHECK(timeout_seconds >= 60 AND timeout_seconds <= 3600),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(agent_name, image, namespace)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_k8s_handlers_agent ON k8s_handlers(agent_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_k8s_handlers_agent_status ON k8s_handlers(agent_name, status);
|
||||
|
||||
-- K8s job run tracking
|
||||
CREATE TABLE IF NOT EXISTS k8s_job_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
handler_id INTEGER NOT NULL REFERENCES k8s_handlers(id) ON DELETE CASCADE,
|
||||
agent_name TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
job_name TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'succeeded', 'failed')),
|
||||
failure_reason TEXT,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_handler ON k8s_job_runs(handler_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_agent_status ON k8s_job_runs(agent_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_runs_job_name ON k8s_job_runs(job_name);
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
# Implementation Plan: Webhooks & Kubernetes Job Runner
|
||||
|
||||
**Branch**: `003-webhooks-k8s-runner` | **Date**: 2026-03-14 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/003-webhooks-k8s-runner/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add event-driven message delivery to SynapBus via HTTP webhooks and Kubernetes Jobs. When an agent receives a message (DM, channel, or @mention), SynapBus asynchronously delivers the payload to registered webhook URLs (HMAC-signed) or creates K8s Jobs. Includes SSRF prevention, loop detection (depth cap at 5), exponential backoff retry (3 attempts), dead letter queue, per-agent rate limiting (60/min), and auto-disable after 50 consecutive failures. K8s runner is optional, auto-detected via in-cluster config.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (from go.mod)
|
||||
**Primary Dependencies**: mark3labs/mcp-go (MCP tools), go-chi/chi (HTTP), golang.org/x/time/rate (rate limiting), k8s.io/client-go (K8s Jobs — optional)
|
||||
**Storage**: modernc.org/sqlite (pure Go), migration 009_webhooks.sql
|
||||
**Testing**: `go test ./...` with CGO_ENABLED=0, table-driven tests, httptest for webhook mocks
|
||||
**Target Platform**: linux/amd64, darwin/arm64 (cross-compile, zero CGO)
|
||||
**Project Type**: Single binary web service with embedded storage
|
||||
**Performance Goals**: Webhook delivery within 5s of message send, 60 deliveries/min/agent rate limit
|
||||
**Constraints**: Zero CGO, single binary, no external runtime dependencies (K8s client is compile-time only, no-op when not in-cluster)
|
||||
**Scale/Scope**: Up to 100 concurrent agents, 10,000 delivery records per agent
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Gate | Status |
|
||||
|-----------|------|--------|
|
||||
| I. Local-First, Single Binary | No new external runtime dependencies | PASS — webhook engine embedded, K8s runner no-op when not in-cluster |
|
||||
| II. MCP-Native | All agent operations as MCP tools | PASS — register_webhook, list_webhooks, delete_webhook, register_k8s_handler, list_k8s_handlers, delete_k8s_handler |
|
||||
| III. Pure Go, Zero CGO | All deps must be pure Go | PASS — net/http, golang.org/x/time, k8s.io/client-go are all pure Go |
|
||||
| IV. Multi-Tenant with Ownership | Webhooks scoped to agent/owner | PASS — per-agent webhooks, owner-only Web UI access |
|
||||
| VIII. Observable by Default | All operations traced | PASS — delivery table + trace entries for all tool calls |
|
||||
| IX. Progressive Complexity | Feature is opt-in | PASS — messaging works without webhooks, K8s disabled when not in-cluster |
|
||||
| X. Web UI as First-Class Citizen | Management UI included | PASS — webhook management, delivery history, dead letters, K8s job runs in UI |
|
||||
|
||||
**Post-design re-check**: All gates still pass. k8s.io/client-go adds ~15MB to binary size but is acceptable as it's pure Go and the runner is a no-op when not in-cluster.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/003-webhooks-k8s-runner/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0 research
|
||||
├── data-model.md # Data model & migration SQL
|
||||
├── quickstart.md # Getting started guide
|
||||
├── contracts/
|
||||
│ └── mcp-tools.md # MCP tool & REST API contracts
|
||||
└── checklists/
|
||||
└── requirements.md # Spec quality checklist
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── webhooks/ # NEW — Webhook engine
|
||||
│ ├── service.go # WebhookService: registration CRUD, event matching
|
||||
│ ├── store.go # SQLiteWebhookStore: DB operations for webhooks & deliveries
|
||||
│ ├── delivery.go # DeliveryEngine: worker pool, dispatch, retry loop
|
||||
│ ├── security.go # HMAC signing, SSRF validation, IP blocking
|
||||
│ ├── ratelimit.go # Per-agent rate limiter (golang.org/x/time/rate)
|
||||
│ ├── service_test.go # Unit tests for WebhookService
|
||||
│ ├── delivery_test.go # Unit tests for DeliveryEngine (with httptest)
|
||||
│ └── security_test.go # Unit tests for SSRF prevention & signing
|
||||
├── k8s/ # NEW — Kubernetes Job Runner
|
||||
│ ├── runner.go # JobRunner interface + K8sJobRunner implementation
|
||||
│ ├── noop.go # NoopRunner for non-K8s environments
|
||||
│ ├── store.go # SQLiteK8sStore: DB operations for handlers & job runs
|
||||
│ ├── watcher.go # Job status watcher (K8s informer)
|
||||
│ ├── runner_test.go # Unit tests with mock K8s client
|
||||
│ └── store_test.go # Unit tests for DB operations
|
||||
├── dispatcher/ # NEW — Event dispatcher (fan-out to webhooks + K8s)
|
||||
│ ├── dispatcher.go # EventDispatcher interface & MultiDispatcher
|
||||
│ └── dispatcher_test.go # Unit tests
|
||||
├── mcp/
|
||||
│ └── webhook_tools.go # NEW — MCP tool registrar for webhook & K8s tools
|
||||
├── api/
|
||||
│ └── webhook_handler.go # NEW — REST API handlers for Web UI
|
||||
└── messaging/
|
||||
└── service.go # MODIFIED — Call dispatcher.Dispatch() after message send
|
||||
|
||||
schema/
|
||||
└── 009_webhooks.sql # NEW — Migration for webhooks, deliveries, k8s tables
|
||||
|
||||
web/src/
|
||||
├── routes/
|
||||
│ ├── agents/[name]/webhooks/+page.svelte # NEW — Webhook management per agent
|
||||
│ ├── dead-letters/webhooks/+page.svelte # NEW — Webhook dead letters
|
||||
│ └── agents/[name]/k8s-handlers/+page.svelte # NEW — K8s handler management
|
||||
└── lib/api/
|
||||
└── client.ts # MODIFIED — Add webhook & K8s API methods
|
||||
|
||||
cmd/synapbus/
|
||||
└── main.go # MODIFIED — Wire WebhookService, K8sRunner, EventDispatcher
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing Go internal package layout. New packages `webhooks/`, `k8s/`, and `dispatcher/` are siblings to existing packages like `messaging/`, `channels/`, `agents/`. The dispatcher pattern decouples message sending from delivery mechanisms.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Addition | Why Needed | Simpler Alternative Rejected Because |
|
||||
|----------|------------|-------------------------------------|
|
||||
| k8s.io/client-go dependency (~15MB) | Native K8s Job support is a key differentiator | HTTP calls to K8s API rejected: reinventing auth, watch, retry logic |
|
||||
| golang.org/x/time dependency | Token bucket rate limiting | Hand-rolled counter rejected: error-prone, no burst support |
|
||||
| Event dispatcher abstraction | Decouple message send from delivery | Direct calls in messaging service rejected: tight coupling, hard to test |
|
||||
@@ -0,0 +1,153 @@
|
||||
# Quickstart: Webhooks & K8s Job Runner
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- SynapBus running (`./synapbus serve --data ./data`)
|
||||
- Two registered agents (e.g., `sender-bot` and `processor-bot`)
|
||||
|
||||
## 1. Register a Webhook (via MCP)
|
||||
|
||||
Using any MCP client connected to SynapBus:
|
||||
|
||||
```
|
||||
Tool: register_webhook
|
||||
Arguments:
|
||||
url: "https://your-endpoint.example.com/webhook"
|
||||
events: ["message.received"]
|
||||
```
|
||||
|
||||
Response includes the HMAC secret (save it — shown only once):
|
||||
```json
|
||||
{
|
||||
"webhook_id": 1,
|
||||
"secret": "a1b2c3d4e5f6...",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Send a Message to Trigger Delivery
|
||||
|
||||
From another agent:
|
||||
```
|
||||
Tool: send_message
|
||||
Arguments:
|
||||
to_agent: "processor-bot"
|
||||
body: "Please analyze dataset #42"
|
||||
priority: 7
|
||||
```
|
||||
|
||||
Within seconds, your webhook endpoint receives:
|
||||
```
|
||||
POST /webhook HTTP/1.1
|
||||
Content-Type: application/json
|
||||
X-SynapBus-Signature: sha256=abc123...
|
||||
X-SynapBus-Event: message.received
|
||||
X-SynapBus-Delivery: d-xyz789
|
||||
X-SynapBus-Depth: 0
|
||||
|
||||
{
|
||||
"event": "message.received",
|
||||
"message": { "id": 5, "from": "sender-bot", "body": "Please analyze dataset #42", ... },
|
||||
"agent": "processor-bot",
|
||||
"timestamp": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Verify the Signature
|
||||
|
||||
```python
|
||||
import hmac, hashlib, json
|
||||
|
||||
secret = b"a1b2c3d4e5f6..." # from registration
|
||||
body = request.body # raw bytes
|
||||
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
assert request.headers["X-SynapBus-Signature"] == expected
|
||||
```
|
||||
|
||||
## 4. View Delivery History (Web UI)
|
||||
|
||||
Navigate to **Agents → processor-bot → Webhooks** to see:
|
||||
- Registered webhooks with status
|
||||
- Delivery history (success/failure)
|
||||
- Dead-lettered deliveries with retry button
|
||||
|
||||
## 5. Kubernetes Job Handler (K8s only)
|
||||
|
||||
When SynapBus runs inside a K8s cluster:
|
||||
|
||||
```
|
||||
Tool: register_k8s_handler
|
||||
Arguments:
|
||||
image: "my-processor:latest"
|
||||
events: ["message.received"]
|
||||
namespace: "agent-jobs"
|
||||
resources: {"memory": "512Mi", "cpu": "250m"}
|
||||
env: {"MODEL": "gpt-4"}
|
||||
```
|
||||
|
||||
When a message arrives, SynapBus creates a K8s Job:
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: synapbus-processor-bot-42
|
||||
namespace: agent-jobs
|
||||
spec:
|
||||
activeDeadlineSeconds: 600
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: handler
|
||||
image: my-processor:latest
|
||||
env:
|
||||
- name: SYNAPBUS_MESSAGE_ID
|
||||
value: "42"
|
||||
- name: SYNAPBUS_MESSAGE_BODY
|
||||
value: "Please analyze dataset #42"
|
||||
- name: SYNAPBUS_FROM_AGENT
|
||||
value: "sender-bot"
|
||||
- name: MODEL
|
||||
value: "gpt-4"
|
||||
resources:
|
||||
limits:
|
||||
memory: 512Mi
|
||||
cpu: 250m
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
## 6. Real-World Example: Multi-Agent Pipeline
|
||||
|
||||
```
|
||||
┌──────────┐ message ┌──────────┐ webhook ┌──────────────┐
|
||||
│ Scanner │───────────────→│ SynapBus │────────────→│ Analyzer │
|
||||
│ Agent │ │ │ │ (webhook) │
|
||||
└──────────┘ │ │ └──────┬───────┘
|
||||
│ │ │
|
||||
│ │ K8s Job │ message
|
||||
│ │◄────────────────────┘
|
||||
│ │──────────────→┌──────────────┐
|
||||
│ │ │ Reporter │
|
||||
└──────────┘ │ (K8s Job) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
1. Scanner agent sends findings to SynapBus
|
||||
2. Analyzer's webhook fires, processes findings, sends summary back
|
||||
3. Reporter's K8s Job fires, generates PDF report in a container
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SYNAPBUS_WEBHOOK_WORKERS` | `10` | Concurrent webhook delivery goroutines |
|
||||
| `SYNAPBUS_ALLOW_HTTP_WEBHOOKS` | `false` | Allow HTTP (non-HTTPS) webhook URLs |
|
||||
| `SYNAPBUS_ALLOW_PRIVATE_NETWORKS` | `false` | Allow webhooks to private IPs |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Register a webhook and see it in `list_webhooks`
|
||||
- [ ] Send a message and verify webhook delivery (check headers + signature)
|
||||
- [ ] Verify SSRF protection: try registering `http://127.0.0.1/hook` (should fail)
|
||||
- [ ] Verify dead letters: register a webhook pointing to a non-existent URL, send a message, check dead letters after retries
|
||||
- [ ] (K8s only) Register a K8s handler and verify Job creation
|
||||
@@ -0,0 +1,158 @@
|
||||
# Research: Webhooks & Kubernetes Job Runner
|
||||
|
||||
**Feature**: 003-webhooks-k8s-runner
|
||||
**Date**: 2026-03-14
|
||||
|
||||
## 1. Webhook Security Best Practices
|
||||
|
||||
### Decision: HMAC-SHA256 Payload Signing
|
||||
- **Approach**: Sign raw JSON body with HMAC-SHA256 using per-webhook shared secret.
|
||||
- **Rationale**: Industry standard used by Stripe (`Stripe-Signature`), GitHub (`X-Hub-Signature-256`), Slack (`X-Slack-Signature`). HMAC-SHA256 provides both authentication and integrity verification.
|
||||
- **Implementation**:
|
||||
- Header: `X-SynapBus-Signature: sha256=<hex digest>`
|
||||
- Include `X-SynapBus-Timestamp` to prevent replay attacks (reject payloads older than 5 minutes)
|
||||
- Secret stored as SHA-256 hash in DB; original shown once at registration
|
||||
- Secret rotation: agent deletes old webhook and creates new one (simple, avoids complexity of dual-secret windows)
|
||||
- **Alternatives considered**: JWT-signed payloads (rejected: heavier, requires key management), API key in header (rejected: no integrity check)
|
||||
|
||||
### Decision: SSRF Prevention via Custom DialContext
|
||||
- **Approach**: Custom `net.Dialer.Control` function that validates resolved IPs before connecting.
|
||||
- **Rationale**: DNS resolution happens at connection time; validating the URL string alone is insufficient (DNS rebinding). Custom DialContext intercepts after DNS resolution but before TCP connect.
|
||||
- **Implementation**:
|
||||
- Block RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
- Block loopback (127.0.0.0/8, ::1)
|
||||
- Block link-local (169.254.0.0/16, fe80::/10)
|
||||
- Block ULA IPv6 (fc00::/7)
|
||||
- Configurable override: `SYNAPBUS_ALLOW_PRIVATE_NETWORKS=true`
|
||||
- URL scheme validation at registration time (HTTPS required, HTTP only with flag)
|
||||
- **Alternatives considered**: URL allowlisting (rejected: too restrictive for general use), DNS pre-resolution + IP pinning (rejected: complex, doesn't prevent rebinding between resolution and connect)
|
||||
|
||||
### Decision: Loop Prevention via Depth Header
|
||||
- **Approach**: `X-SynapBus-Depth` header incremented on each webhook-triggered message. Dead-letter at depth 5.
|
||||
- **Rationale**: Simple, stateless approach. Each hop is independently verifiable. No need for distributed tracing infrastructure.
|
||||
- **Implementation**:
|
||||
- When a message arrives via a webhook-triggered action, incoming depth is extracted from context
|
||||
- Each outgoing webhook delivery increments depth by 1
|
||||
- At depth >= 5, delivery is dead-lettered with reason "loop depth exceeded"
|
||||
- Depth is stored in the delivery record for debugging
|
||||
- **Alternatives considered**: Circuit breakers per agent pair (rejected: stateful, complex), global rate limiting only (rejected: doesn't catch fast loops within rate window)
|
||||
|
||||
### Decision: Token Bucket Rate Limiting with golang.org/x/time/rate
|
||||
- **Approach**: Per-agent token bucket rate limiter, 60 tokens/minute (1/second sustained).
|
||||
- **Rationale**: `golang.org/x/time/rate` is pure Go, stdlib-adjacent, well-tested. Token bucket allows bursts while capping sustained rate.
|
||||
- **Implementation**:
|
||||
- `rate.NewLimiter(rate.Every(time.Second), 60)` per agent
|
||||
- Excess deliveries are queued (Wait), not dropped
|
||||
- Limiter instances stored in sync.Map keyed by agent name
|
||||
- Limiter garbage-collected when agent has no active webhooks
|
||||
- **Alternatives considered**: Leaky bucket (rejected: no burst tolerance), fixed window counter (rejected: thundering herd at window boundaries)
|
||||
|
||||
### Decision: Exponential Backoff with 3 Retries
|
||||
- **Approach**: 3 attempts at 1s, 5s, 30s intervals. Dead-letter after exhaustion.
|
||||
- **Rationale**: Matches common industry practice (GitHub: 10s, 60s, 360s; Stripe: exponential up to 3 days but we're simpler). Short total window (~36s) prevents stale delivery.
|
||||
- **Implementation**:
|
||||
- Retry intervals: [1s, 5s, 30s]
|
||||
- Respect `Retry-After` header for 429 responses
|
||||
- HTTP timeout per attempt: 10 seconds
|
||||
- No follow redirects (prevent open redirect attacks)
|
||||
- Auto-disable webhook after 50 consecutive failures across all deliveries
|
||||
- **Alternatives considered**: Longer retry window with jitter (rejected: messages become stale), infinite retry (rejected: dead letters need visibility)
|
||||
|
||||
## 2. Go Libraries & Patterns
|
||||
|
||||
### Decision: Standard Library net/http for Delivery (No External Webhook Library)
|
||||
- **Approach**: Use `net/http.Client` with custom `Transport` for SSRF protection. No external webhook library.
|
||||
- **Rationale**: SynapBus needs are specific (SSRF prevention, depth tracking, custom signing). External libraries like svix-go are SDKs for their SaaS, not embeddable engines. Gitea and Mattermost both use custom implementations.
|
||||
- **Implementation**:
|
||||
- Custom `http.Transport` with `DialContext` that validates IPs
|
||||
- `CheckRedirect: func(...) error { return http.ErrUseLastResponse }` to block redirects
|
||||
- Timeouts: `Timeout: 10s`, `TLSHandshakeTimeout: 5s`, `ResponseHeaderTimeout: 5s`
|
||||
- Connection pooling via default Transport pool settings
|
||||
- **Alternatives considered**: svix-webhooks Go SDK (rejected: SaaS-oriented, not embeddable), go-resty (rejected: unnecessary abstraction)
|
||||
|
||||
### Decision: Goroutine Worker Pool with Buffered Channel (No External Pool Library)
|
||||
- **Approach**: Fixed-size goroutine pool reading from buffered channel. Pool size configurable via `SYNAPBUS_WEBHOOK_WORKERS` (default 10).
|
||||
- **Rationale**: Simple, well-understood pattern. No external dependency needed. Matches SynapBus's approach of minimal dependencies.
|
||||
- **Implementation**:
|
||||
- `deliveryChan chan *DeliveryJob` (buffered, size = workers * 10)
|
||||
- N worker goroutines consuming from channel
|
||||
- Graceful shutdown: close channel, wait for in-flight deliveries
|
||||
- On startup: re-queue pending/retrying deliveries from DB
|
||||
- **Alternatives considered**: gammazero/workerpool (rejected: unnecessary dependency for simple pattern), pond (rejected: same reason)
|
||||
|
||||
### Decision: golang.org/x/time/rate for Rate Limiting
|
||||
- **Approach**: Use `golang.org/x/time/rate` from the Go extended stdlib.
|
||||
- **Rationale**: Pure Go, well-maintained, already in Go module ecosystem. Token bucket is the right algorithm for bursty webhook delivery.
|
||||
- **Alternatives considered**: uber-go/ratelimit (rejected: leaky bucket, no burst), hand-rolled sliding window (rejected: error-prone)
|
||||
|
||||
## 3. Kubernetes Job Runner
|
||||
|
||||
### Decision: k8s.io/client-go with In-Cluster Config Auto-Detection
|
||||
- **Approach**: Use `client-go` with `rest.InClusterConfig()`. If it fails, K8s features are disabled (no-op).
|
||||
- **Rationale**: `client-go` is the official Go client, pure Go, widely used. In-cluster config uses the ServiceAccount token mounted by K8s automatically.
|
||||
- **Implementation**:
|
||||
- `internal/k8s/runner.go` with interface `JobRunner`
|
||||
- `NewJobRunner()` attempts `rest.InClusterConfig()`. If error, returns `NoopRunner`
|
||||
- `JobRunner.CreateJob(ctx, handler, message)` creates K8s Job
|
||||
- `JobRunner.WatchJobs(ctx)` watches for Job completions/failures
|
||||
- `NoopRunner` satisfies interface but returns "not available" errors
|
||||
- **Alternatives considered**: Direct REST API calls (rejected: reinventing client-go), CRDs (rejected: over-engineering), Helm templates (rejected: not programmatic)
|
||||
|
||||
### Decision: Environment Variables for Message Data
|
||||
- **Approach**: Inject message data as env vars (`SYNAPBUS_MESSAGE_ID`, `SYNAPBUS_MESSAGE_BODY`, etc.)
|
||||
- **Rationale**: Simplest approach. Works for messages up to ~32KB (env var limit varies by OS). For larger messages, body could reference a message ID for the Job to fetch via MCP.
|
||||
- **Implementation**:
|
||||
- `SYNAPBUS_MESSAGE_ID` - Message ID (integer as string)
|
||||
- `SYNAPBUS_MESSAGE_BODY` - Message body (truncated to 32KB)
|
||||
- `SYNAPBUS_FROM_AGENT` - Sender agent name
|
||||
- `SYNAPBUS_EVENT` - Event type
|
||||
- `SYNAPBUS_CHANNEL` - Channel name (if applicable)
|
||||
- `SYNAPBUS_TIMESTAMP` - Event timestamp (ISO 8601)
|
||||
- Plus user-defined env vars from handler registration
|
||||
- **Alternatives considered**: ConfigMap (rejected: requires cleanup, race conditions), command args (rejected: limited size, visible in `ps`), volume mount (rejected: complex, requires PV)
|
||||
|
||||
### Decision: Job Watcher Goroutine with Informer
|
||||
- **Approach**: Use a K8s informer to watch Job status changes in registered namespaces.
|
||||
- **Rationale**: Informers are the standard K8s watch pattern with caching, reconnection, and backoff built in.
|
||||
- **Implementation**:
|
||||
- Single informer watching Jobs with label `app.kubernetes.io/managed-by=synapbus`
|
||||
- On Job completion/failure: update `k8s_job_runs` table
|
||||
- On startup: reconcile existing Jobs with DB records
|
||||
- **Alternatives considered**: Polling (rejected: wasteful, slow), CRD controller (rejected: over-engineering)
|
||||
|
||||
### Decision: RBAC with Minimal Permissions
|
||||
- **Approach**: SynapBus ServiceAccount needs: create/get/list/watch/delete Jobs in agent namespaces.
|
||||
- **Required RBAC**:
|
||||
- `apiGroups: ["batch"]`, `resources: ["jobs"]`, `verbs: ["create", "get", "list", "watch", "delete"]`
|
||||
- `apiGroups: [""]`, `resources: ["pods/log"]`, `verbs: ["get"]` (for log viewing)
|
||||
- RoleBinding per namespace (not ClusterRoleBinding)
|
||||
- **Alternatives considered**: ClusterRole (rejected: violates least privilege), per-agent ServiceAccount (rejected: complexity without proportional security benefit for v1)
|
||||
|
||||
## 4. Architecture Decisions
|
||||
|
||||
### Decision: Separate internal/webhooks/ and internal/k8s/ Packages
|
||||
- **Approach**: Two new packages:
|
||||
- `internal/webhooks/` - Webhook registration, delivery engine, SSRF protection, rate limiting
|
||||
- `internal/k8s/` - K8s handler registration, job runner, job watcher
|
||||
- **Rationale**: Separation of concerns. K8s package can be no-op when not in cluster. Webhook package has no K8s dependency.
|
||||
- **Shared**: Both use a common `EventDispatcher` interface called by the messaging service when events occur.
|
||||
|
||||
### Decision: Event Dispatcher Pattern for Message-to-Delivery
|
||||
- **Approach**: `internal/webhooks/dispatcher.go` with interface:
|
||||
```
|
||||
type EventDispatcher interface {
|
||||
Dispatch(ctx context.Context, event Event) error
|
||||
}
|
||||
```
|
||||
The messaging service calls `dispatcher.Dispatch()` after sending a message. The dispatcher fans out to webhook and K8s deliveries.
|
||||
- **Rationale**: Decouples message sending from delivery mechanisms. Easy to add new delivery types later.
|
||||
|
||||
### Decision: Migration Number 009
|
||||
- **Approach**: Use `009_webhooks.sql` for all new tables (webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs).
|
||||
- **Rationale**: Next available migration number after existing 008_dead_letters.sql.
|
||||
|
||||
### Decision: New Dependencies
|
||||
- `golang.org/x/time` - Rate limiting (pure Go, stdlib-adjacent)
|
||||
- `k8s.io/client-go` - K8s API client (pure Go, optional at runtime)
|
||||
- `k8s.io/api` - K8s API types (transitive from client-go)
|
||||
- No new CGO dependencies.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Feature Specification: Webhooks & Kubernetes Job Runner
|
||||
|
||||
**Feature Branch**: `003-webhooks-k8s-runner`
|
||||
**Created**: 2026-03-14
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Webhooks & Kubernetes Job Runner — event-driven agent message delivery via HTTP webhooks and Kubernetes Jobs"
|
||||
|
||||
## Constitution Compliance
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | Compliant | Webhook engine is embedded. K8s runner is optional and only activates when in-cluster. No new external runtime dependencies required for core functionality. |
|
||||
| II. MCP-Native | Compliant | Webhook and K8s handler registration exposed as MCP tools. |
|
||||
| III. Pure Go, Zero CGO | Compliant | Uses net/http for webhook delivery. k8s.io/client-go is pure Go. |
|
||||
| IV. Multi-Tenant with Ownership | Compliant | Webhooks and K8s handlers are per-agent, scoped to agent's owner. |
|
||||
| V. Embedded OAuth 2.1 | Not Applicable | No auth changes required. |
|
||||
| VI. Semantic-Ready Storage | Not Applicable | No vector search changes. |
|
||||
| VII. Swarm Intelligence Patterns | Compatible | Webhooks can trigger on channel messages including blackboard/auction channels. |
|
||||
| VIII. Observable by Default | Compliant | All deliveries tracked in delivery tables with full audit trail. Dead letters visible in UI. |
|
||||
| IX. Progressive Complexity | Compliant | Webhooks are opt-in per agent. K8s runner only activates when in-cluster. Basic messaging works without either. |
|
||||
| X. Web UI as First-Class Citizen | Compliant | Webhook management, delivery history, dead letters, and K8s job status visible in Web UI. |
|
||||
|
||||
## Assumptions
|
||||
|
||||
These assumptions resolve ambiguities in the feature description with secure, reasonable defaults:
|
||||
|
||||
1. **Webhook secret length**: Minimum 32 bytes, generated by SynapBus if not provided by the agent. Stored hashed (SHA-256) in the database; the plaintext is shown to the agent exactly once at registration time.
|
||||
2. **URL validation timing**: URLs are validated at registration time (syntax, scheme, IP range). DNS resolution is re-checked at delivery time to prevent DNS rebinding attacks.
|
||||
3. **K8s client-go as optional dependency**: The K8s runner compiles into the binary but is a no-op when not running in-cluster. The `k8s.io/client-go` dependency is acceptable as it is pure Go.
|
||||
4. **Webhook payload size limit**: Maximum 1MB per payload. Messages with attachments include attachment metadata but not attachment content in the webhook payload.
|
||||
5. **K8s Job resource defaults**: If no resource limits are specified, defaults to 256Mi memory and 100m CPU. Maximum configurable: 2Gi memory, 1 CPU.
|
||||
6. **Dead letter retention**: Dead-lettered deliveries are retained for 30 days, then auto-purged.
|
||||
7. **K8s namespace**: Agents register handlers in a specific namespace. If not specified, defaults to the namespace SynapBus itself runs in.
|
||||
8. **Concurrent webhook workers**: Default pool size of 10 goroutines, configurable via `SYNAPBUS_WEBHOOK_WORKERS` environment variable.
|
||||
9. **HTTPS enforcement**: In production mode (default), webhook URLs MUST use HTTPS. A `SYNAPBUS_ALLOW_HTTP_WEBHOOKS=true` flag permits HTTP for development/testing.
|
||||
10. **K8s Job timeout**: Jobs have a 10-minute default timeout (`activeDeadlineSeconds`), configurable per handler registration up to 60 minutes.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Agent Registers a Webhook and Receives Event-Driven Messages (Priority: P1)
|
||||
|
||||
An AI agent operator wants their agent to react immediately when it receives a direct message, instead of polling the inbox every 30 seconds. The operator registers a webhook URL via the `register_webhook` MCP tool, specifying which events to subscribe to and a shared secret for signature verification. When another agent sends a message to this agent, SynapBus immediately POSTs the message payload to the registered URL, signed with HMAC-SHA256. The receiving endpoint verifies the signature and processes the message. The human owner can see the delivery status in the Web UI.
|
||||
|
||||
**Why this priority**: This is the core value proposition — transforming agents from poll-based to event-driven. Without this, neither the security features nor K8s integration have a substrate to build on.
|
||||
|
||||
**Independent Test**: Can be fully tested by registering an agent with a webhook, sending it a message from another agent, and verifying the HTTP POST is delivered with correct headers and signature. Delivers immediate value: instant message delivery without polling.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent "processor" is registered, **When** it calls `register_webhook` with `url: "https://processor.example.com/webhook"`, `events: ["message.received"]`, and `secret: "my-secret-key-at-least-32-bytes!!"`, **Then** the webhook is stored for the agent, and a success response is returned with the webhook ID. The plaintext secret is shown once in the response.
|
||||
|
||||
2. **Given** agent "processor" has a registered webhook for `message.received`, **When** agent "sender" calls `send_message` with `to_agent: "processor"`, **Then** within 5 seconds SynapBus POSTs the message payload to `https://processor.example.com/webhook` with headers `X-SynapBus-Signature: sha256=<HMAC>`, `X-SynapBus-Event: message.received`, `X-SynapBus-Delivery: <delivery-id>`, and `X-SynapBus-Depth: 0`.
|
||||
|
||||
3. **Given** agent "processor" already has 3 registered webhooks, **When** it calls `register_webhook` for a 4th, **Then** the system returns an error: "maximum 3 webhooks per agent reached".
|
||||
|
||||
4. **Given** agent "processor" has a registered webhook, **When** it calls `list_webhooks`, **Then** the response lists all registered webhooks with their IDs, URLs (masked after the domain), events, status (active/disabled), and creation timestamp.
|
||||
|
||||
5. **Given** agent "processor" has a registered webhook with ID 7, **When** it calls `delete_webhook` with `webhook_id: 7`, **Then** the webhook is removed and no further deliveries are sent to that URL.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Webhook Security: Signature Verification, SSRF Prevention, and Loop Detection (Priority: P1)
|
||||
|
||||
A platform administrator needs confidence that the webhook system cannot be exploited. Webhook payloads are signed with HMAC-SHA256 so recipients can verify authenticity. URLs pointing to private networks (RFC1918, loopback, link-local) are blocked by default to prevent SSRF. When agent A's webhook triggers a message to agent B, whose webhook triggers a message back to agent A, the depth counter in `X-SynapBus-Depth` increments each hop. At depth 5, the chain is terminated and the delivery is dead-lettered with a "loop depth exceeded" reason.
|
||||
|
||||
**Why this priority**: Security is co-equal with the core webhook delivery. Shipping webhooks without SSRF prevention and loop detection would create critical vulnerabilities. These must be built into the initial implementation, not bolted on later.
|
||||
|
||||
**Independent Test**: Can be tested by attempting to register webhook URLs with private IPs (should fail), verifying HMAC signatures match expected values, and setting up a circular webhook chain that terminates at depth 5.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a webhook delivery is about to be sent, **When** the system computes the HMAC-SHA256 of the JSON payload body using the agent's registered secret, **Then** the resulting hex digest is placed in the `X-SynapBus-Signature` header as `sha256=<hex>`, and the receiving endpoint can independently compute the same HMAC to verify the payload was not tampered with.
|
||||
|
||||
2. **Given** an agent calls `register_webhook` with `url: "http://192.168.1.100/webhook"` and private network access is not enabled, **Then** the system rejects the registration with an error: "webhook URL resolves to a private network address; set SYNAPBUS_ALLOW_PRIVATE_NETWORKS=true to override".
|
||||
|
||||
3. **Given** an agent calls `register_webhook` with `url: "http://localhost:8080/hook"`, **Then** the system rejects it unless `SYNAPBUS_ALLOW_HTTP_WEBHOOKS=true` is set (for dev mode).
|
||||
|
||||
4. **Given** a webhook chain has reached depth 4 (A -> B -> C -> D -> E), **When** E's webhook sends a message that would trigger F's webhook, **Then** the delivery to F includes `X-SynapBus-Depth: 5`, which equals the limit, so SynapBus dead-letters the delivery with reason "webhook loop depth exceeded (max: 5)" and does NOT deliver it.
|
||||
|
||||
5. **Given** an agent registers a webhook URL that initially resolves to a public IP, **When** at delivery time DNS re-resolution shows it now resolves to 127.0.0.1 (DNS rebinding), **Then** the delivery is blocked and dead-lettered with reason "webhook URL resolved to blocked IP address".
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Webhook Retry, Rate Limiting, and Dead Letters (Priority: P2)
|
||||
|
||||
When a webhook endpoint returns a 5xx error or times out, SynapBus retries delivery with exponential backoff (1s, 5s, 30s). After 3 failed attempts, the delivery is moved to the dead letter queue. If a webhook accumulates 50 consecutive failed deliveries, it is automatically disabled. The human owner can view dead-lettered deliveries in the Web UI, see the failure reason for each, manually retry individual deliveries, and re-enable disabled webhooks. Per-agent rate limiting caps delivery at 60 per minute to prevent abuse.
|
||||
|
||||
**Why this priority**: Retry and dead letter handling are essential for production reliability but the system is demonstrable without them (deliveries just fail on first attempt). Rate limiting prevents runaway agents from overwhelming external services.
|
||||
|
||||
**Independent Test**: Can be tested by registering a webhook pointing at a mock server that returns 500, sending a message, verifying 3 retry attempts occur with correct backoff intervals, and confirming the delivery appears in the dead letter queue.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a webhook delivery returns HTTP 503, **When** the retry policy executes, **Then** the system retries after 1 second, then 5 seconds, then 30 seconds. After all 3 retries fail, the delivery is marked as `dead_lettered` with the last HTTP status and error message recorded.
|
||||
|
||||
2. **Given** a webhook delivery returns HTTP 429 (Too Many Requests), **When** the response includes a `Retry-After` header, **Then** the system respects the `Retry-After` delay before the next attempt.
|
||||
|
||||
3. **Given** an agent's webhook has accumulated 50 consecutive failed deliveries (across any mix of messages), **When** the 50th failure is recorded, **Then** the webhook status is changed to `disabled`, a system notification is generated for the agent's owner, and no further deliveries are attempted until the owner re-enables the webhook.
|
||||
|
||||
4. **Given** an agent has a rate limit of 60 deliveries per minute, **When** 61 webhook deliveries are triggered within 1 minute, **Then** the 61st delivery is queued and delivered after the rate window resets, not dropped.
|
||||
|
||||
5. **Given** the dead letter queue contains a failed delivery, **When** the human owner views it in the Web UI and clicks "Retry", **Then** the delivery is re-queued for immediate delivery with the retry counter reset.
|
||||
|
||||
6. **Given** a webhook is in `disabled` state due to consecutive failures, **When** the human owner re-enables it via the Web UI, **Then** the consecutive failure counter resets to 0 and the webhook status returns to `active`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Kubernetes Job Runner for Message Processing (Priority: P2)
|
||||
|
||||
When SynapBus is deployed in a Kubernetes cluster, an agent operator registers a K8s job handler instead of (or in addition to) a webhook. When the agent receives a matching event, SynapBus creates a Kubernetes Job that runs a specified container image with the message payload injected as environment variables. The job's status (running, succeeded, failed) is tracked and visible in the Web UI. Job logs can be viewed by the agent's owner.
|
||||
|
||||
**Why this priority**: K8s integration extends the webhook concept to cloud-native workloads where agents are containerized. This is lower priority than webhooks because it only applies when SynapBus runs in-cluster, but it is a key differentiator for Kubernetes deployments.
|
||||
|
||||
**Independent Test**: Can be tested by running SynapBus in a K8s cluster (or with a mock K8s client), registering a K8s handler for an agent, sending a message, and verifying a Job is created with the correct spec. The job's completion status should be reflected in the Web UI.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** SynapBus is running inside a Kubernetes cluster, **When** agent "ml-worker" calls `register_k8s_handler` with `image: "ml-pipeline:latest"`, `events: ["message.received"]`, `namespace: "ml-jobs"`, `resources: {"memory": "512Mi", "cpu": "250m"}`, `env: {"MODEL_NAME": "gpt-4"}`, **Then** the handler is registered and a success response is returned with the handler ID.
|
||||
|
||||
2. **Given** agent "ml-worker" has a registered K8s handler for `message.received`, **When** agent "orchestrator" sends a message to "ml-worker", **Then** SynapBus creates a Kubernetes Job named `synapbus-ml-worker-<message-id>` in namespace `ml-jobs` with the registered image, resource limits, environment variables, and additional environment variables `SYNAPBUS_MESSAGE_ID`, `SYNAPBUS_MESSAGE_BODY`, `SYNAPBUS_FROM_AGENT`, `SYNAPBUS_EVENT`.
|
||||
|
||||
3. **Given** SynapBus is NOT running inside a Kubernetes cluster, **When** an agent calls `register_k8s_handler`, **Then** the system returns an error: "Kubernetes job runner is not available (not running in-cluster)".
|
||||
|
||||
4. **Given** a K8s Job has been created for a message delivery, **When** the Job completes successfully, **Then** the job run record is updated to status `succeeded` with the completion timestamp.
|
||||
|
||||
5. **Given** a K8s Job has been created, **When** the Job fails (backoff limit exceeded), **Then** the job run record is updated to status `failed` with the failure reason extracted from the Job's conditions.
|
||||
|
||||
6. **Given** a K8s handler is registered, **When** the human owner views the agent's handlers in the Web UI, **Then** they see the handler details, recent job runs with status, and can click into a job run to view its logs.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - @Mention Webhook Triggers in Channels (Priority: P3)
|
||||
|
||||
When agent "analyzer" is @mentioned in a channel message (e.g., "@analyzer please review this"), if "analyzer" has a webhook registered for the `message.mentioned` event, SynapBus delivers the message to the webhook URL. This works for both webhook URLs and K8s job handlers. The agent does not need to be actively polling the channel — the @mention triggers the event.
|
||||
|
||||
**Why this priority**: This extends the event system to channel @mentions, which is important for multi-agent collaboration patterns but builds on top of the core webhook/K8s infrastructure.
|
||||
|
||||
**Independent Test**: Can be tested by registering a webhook for `message.mentioned`, sending a channel message that @mentions the agent, and verifying the webhook delivery includes the mention context.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent "analyzer" has a webhook registered for `message.mentioned` on channel "general", **When** agent "reporter" sends a message "@analyzer check this CVE" to channel "general", **Then** SynapBus delivers a webhook with event `message.mentioned` to analyzer's webhook URL, with the message body and channel context.
|
||||
|
||||
2. **Given** agent "analyzer" has a webhook for `message.mentioned` but NOT for `channel.message`, **When** a message is posted to a channel "analyzer" is a member of WITHOUT an @mention, **Then** no webhook is delivered to "analyzer" for that message.
|
||||
|
||||
3. **Given** an agent has both a webhook and a K8s handler registered for `message.mentioned`, **When** the agent is @mentioned, **Then** BOTH the webhook and the K8s Job are triggered (they are independent delivery mechanisms).
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Webhook and K8s Handler Management in Web UI (Priority: P3)
|
||||
|
||||
The human owner logs into the SynapBus Web UI and navigates to their agent's settings. They see a "Webhooks & Handlers" section showing all registered webhooks and K8s handlers. For webhooks, they can see delivery history, retry failed deliveries, view the dead letter queue, and toggle webhook active/disabled status. For K8s handlers, they can see job run history with status and view logs. They can also register, edit, and delete webhooks/handlers from the UI (the UI calls the internal REST API, not MCP).
|
||||
|
||||
**Why this priority**: The Web UI for management is essential for human oversight (Constitution Principle VIII) but the system is fully functional via MCP tools without it.
|
||||
|
||||
**Independent Test**: Can be tested by navigating to the webhook management page, verifying all CRUD operations work, checking delivery history displays correctly, and confirming dead letter retry works end-to-end.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a human owner is logged into the Web UI, **When** they navigate to an agent's webhook settings, **Then** they see a list of all registered webhooks with URL, events, status, and delivery success rate.
|
||||
|
||||
2. **Given** the dead letter queue has entries, **When** the owner views the dead letters page, **Then** they see each failed delivery with: target URL, event type, failure reason, number of attempts, timestamps, and a "Retry" button.
|
||||
|
||||
3. **Given** a K8s handler has recent job runs, **When** the owner clicks on a job run, **Then** they see the job name, namespace, status, duration, and a link to view logs (fetched from the K8s API).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a webhook URL becomes unreachable permanently? After 50 consecutive failures, the webhook is auto-disabled. The owner is notified and must manually re-enable it.
|
||||
- What happens when a webhook and a K8s handler are both registered for the same event on the same agent? Both fire independently. A failure in one does not affect the other.
|
||||
- What happens when SynapBus is restarted while webhook deliveries are in-flight? Pending deliveries in the database with status `pending` or `retrying` are re-queued on startup.
|
||||
- What happens when the K8s API is temporarily unavailable? Job creation is retried 3 times with exponential backoff, then dead-lettered.
|
||||
- What happens when an agent is deleted but has registered webhooks? All webhooks and K8s handlers for the agent are cascade-deleted, and any pending deliveries are cancelled.
|
||||
- What happens when the webhook endpoint returns HTTP 301/302 redirect? Redirects are NOT followed to prevent open redirect attacks. The delivery is treated as a failure with reason "redirects not allowed".
|
||||
- What happens when two messages arrive simultaneously for the same agent? Both are delivered independently via the worker pool. The rate limiter ensures they don't exceed the per-agent limit.
|
||||
- What happens when a K8s Job exceeds its timeout? The Job is terminated by Kubernetes via `activeDeadlineSeconds`, and SynapBus records the status as `failed` with reason "deadline exceeded".
|
||||
- What happens when an agent registers a webhook URL with a path containing query parameters? The URL is stored as-is (query params preserved). Validation only checks scheme and host.
|
||||
- What happens when the webhook payload exceeds 1MB? The payload is truncated to include message metadata without the full body, and a `truncated: true` field is added to the payload.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### Webhook Registration & Management
|
||||
|
||||
- **FR-001**: System MUST expose a `register_webhook` MCP tool that accepts `url` (string, required), `events` (array of strings, required, values: `message.received`, `message.mentioned`, `channel.message`), and `secret` (string, optional, minimum 32 characters if provided; auto-generated if omitted). Returns webhook ID and the secret (shown once).
|
||||
- **FR-002**: System MUST enforce a maximum of 3 webhooks per agent.
|
||||
- **FR-003**: System MUST expose a `list_webhooks` MCP tool that returns all webhooks registered by the calling agent with their IDs, URLs (domain visible, path masked), event subscriptions, and status.
|
||||
- **FR-004**: System MUST expose a `delete_webhook` MCP tool that removes a webhook by ID. Only the owning agent can delete its own webhooks.
|
||||
- **FR-005**: Webhook URLs MUST use HTTPS in production mode. HTTP is permitted only when `SYNAPBUS_ALLOW_HTTP_WEBHOOKS=true` is set.
|
||||
- **FR-006**: System MUST reject webhook URLs that resolve to private network addresses (RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16; loopback: 127.0.0.0/8; link-local: 169.254.0.0/16; IPv6 equivalents: ::1, fc00::/7, fe80::/10) unless `SYNAPBUS_ALLOW_PRIVATE_NETWORKS=true` is set.
|
||||
|
||||
#### Webhook Delivery
|
||||
|
||||
- **FR-007**: When a message event matches an agent's webhook subscription, the system MUST asynchronously POST a JSON payload to the webhook URL within 5 seconds of the event occurring (under normal load).
|
||||
- **FR-008**: The webhook payload MUST include: `event` (string), `message` (object with `id`, `from`, `body`, `channel` if applicable, `priority`, `metadata`), `agent` (the receiving agent's name), `timestamp` (ISO 8601), and `delivery_id` (unique per delivery).
|
||||
- **FR-009**: Every webhook delivery MUST include headers: `Content-Type: application/json`, `X-SynapBus-Signature: sha256=<HMAC-SHA256 hex digest>`, `X-SynapBus-Event: <event type>`, `X-SynapBus-Delivery: <delivery-id>`, `X-SynapBus-Depth: <integer>`.
|
||||
- **FR-010**: The HMAC-SHA256 signature MUST be computed over the raw JSON payload body using the agent's registered webhook secret.
|
||||
- **FR-011**: System MUST perform DNS resolution at delivery time and block delivery if the resolved IP falls within private network ranges (DNS rebinding prevention).
|
||||
- **FR-012**: System MUST NOT follow HTTP redirects (3xx responses). Redirected deliveries are treated as failures.
|
||||
|
||||
#### Loop Prevention & Rate Limiting
|
||||
|
||||
- **FR-013**: System MUST track webhook chain depth via the `X-SynapBus-Depth` header. When an incoming message originates from a webhook-triggered action, the depth is incremented. At depth 5, the delivery is rejected and dead-lettered.
|
||||
- **FR-014**: System MUST enforce per-agent rate limiting of 60 webhook deliveries per minute. Excess deliveries are queued, not dropped.
|
||||
- **FR-015**: System MUST implement exponential backoff retry: 3 attempts at intervals of 1 second, 5 seconds, and 30 seconds. After all retries fail, the delivery is dead-lettered.
|
||||
- **FR-016**: System MUST respect `Retry-After` headers from 429 responses when scheduling retries.
|
||||
|
||||
#### Dead Letters & Auto-Disable
|
||||
|
||||
- **FR-017**: Failed deliveries (after exhausting retries) MUST be stored in a dead letter table with: delivery ID, webhook ID, agent name, event, payload, failure reason, HTTP status, attempt count, timestamps.
|
||||
- **FR-018**: After 50 consecutive failed deliveries to a webhook, the system MUST automatically set the webhook status to `disabled` and generate a notification for the agent's owner.
|
||||
- **FR-019**: Dead-lettered deliveries MUST be retained for 30 days, then automatically purged.
|
||||
- **FR-020**: The system MUST support manual retry of individual dead-lettered deliveries via the Web UI (REST API).
|
||||
|
||||
#### Kubernetes Job Runner
|
||||
|
||||
- **FR-021**: System MUST expose a `register_k8s_handler` MCP tool that accepts `image` (string, required), `events` (array of strings, required, same values as webhooks), `namespace` (string, optional, defaults to SynapBus's own namespace), `resources` (object with `memory` and `cpu`, optional, defaults to 256Mi/100m), `env` (object of key-value string pairs, optional), and `timeout_seconds` (integer, optional, default 600, max 3600).
|
||||
- **FR-022**: System MUST auto-detect whether it is running inside a Kubernetes cluster. If not in-cluster, `register_k8s_handler` MUST return an error.
|
||||
- **FR-023**: When a message event matches an agent's K8s handler subscription, the system MUST create a Kubernetes Job with name `synapbus-<agent>-<message-id>` in the specified namespace.
|
||||
- **FR-024**: The K8s Job MUST inject message data as environment variables: `SYNAPBUS_MESSAGE_ID`, `SYNAPBUS_MESSAGE_BODY`, `SYNAPBUS_FROM_AGENT`, `SYNAPBUS_EVENT`, `SYNAPBUS_CHANNEL` (if applicable), `SYNAPBUS_TIMESTAMP`.
|
||||
- **FR-025**: System MUST enforce a maximum of 3 K8s handlers per agent (same limit as webhooks).
|
||||
- **FR-026**: System MUST track K8s Job status (pending, running, succeeded, failed) in a job runs table with: run ID, handler ID, agent name, job name, namespace, status, start time, completion time, failure reason.
|
||||
- **FR-027**: K8s Jobs MUST have `ttlSecondsAfterFinished: 3600` (cleanup after 1 hour) and `activeDeadlineSeconds` set to the handler's configured timeout.
|
||||
- **FR-028**: System MUST support a `list_k8s_handlers` MCP tool and a `delete_k8s_handler` MCP tool, mirroring the webhook management tools.
|
||||
|
||||
#### Observability
|
||||
|
||||
- **FR-029**: All webhook deliveries (success and failure) MUST be recorded in the delivery tracking table with full details.
|
||||
- **FR-030**: All K8s job creations and status changes MUST be recorded in the job runs table.
|
||||
- **FR-031**: All webhook and K8s handler MCP tool calls MUST produce trace entries (per Constitution Principle VIII).
|
||||
- **FR-032**: The Web UI MUST display webhook delivery history, dead letter queue, K8s job run history, and handler management for each agent.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Webhook**: A registered HTTP endpoint for event-driven delivery. Key attributes: `id`, `agent_name`, `url`, `events` (array), `secret_hash` (SHA-256 of the HMAC secret), `status` (active/disabled), `consecutive_failures`, `created_at`, `updated_at`. An agent may have at most 3 webhooks. Relates to: Agent (owner), WebhookDelivery (delivery history).
|
||||
|
||||
- **WebhookDelivery**: A record of a webhook delivery attempt. Key attributes: `id`, `webhook_id`, `agent_name`, `event`, `payload` (the JSON that was/would be sent), `status` (pending/delivered/retrying/dead_lettered), `http_status` (last response code), `attempts`, `last_error`, `next_retry_at`, `depth` (chain depth), `created_at`, `delivered_at`. Relates to: Webhook (parent).
|
||||
|
||||
- **K8sHandler**: A registered Kubernetes Job template for event-driven processing. Key attributes: `id`, `agent_name`, `image`, `events` (array), `namespace`, `resources` (memory/cpu), `env` (key-value pairs), `timeout_seconds`, `status` (active/disabled), `created_at`, `updated_at`. An agent may have at most 3 handlers. Relates to: Agent (owner), K8sJobRun (run history).
|
||||
|
||||
- **K8sJobRun**: A record of a Kubernetes Job execution. Key attributes: `id`, `handler_id`, `agent_name`, `job_name`, `namespace`, `message_id`, `status` (pending/running/succeeded/failed), `start_time`, `completion_time`, `failure_reason`, `created_at`. Relates to: K8sHandler (parent), Message (trigger).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An agent can register a webhook and receive a message delivery within 5 seconds of the triggering message being sent, under normal load with fewer than 100 concurrent agents.
|
||||
|
||||
- **SC-002**: Webhook payloads are correctly signed with HMAC-SHA256, and a recipient can independently verify the signature using the shared secret. Verified by end-to-end test with signature check.
|
||||
|
||||
- **SC-003**: Registration of webhook URLs pointing to private network addresses (10.x, 172.16-31.x, 192.168.x, 127.x, ::1) is rejected by default. Verified by attempting registration with each blocked range.
|
||||
|
||||
- **SC-004**: A circular webhook chain (agent A -> B -> A -> B...) terminates at depth 5, producing a dead letter entry with a clear loop-detection reason. The chain does NOT cause resource exhaustion.
|
||||
|
||||
- **SC-005**: A webhook endpoint returning 500 errors receives exactly 3 delivery attempts (1s, 5s, 30s backoff) before the delivery is dead-lettered. Total time from first attempt to dead-letter is approximately 36 seconds.
|
||||
|
||||
- **SC-006**: After 50 consecutive failures, the webhook is automatically disabled. No further deliveries are attempted until the owner re-enables it.
|
||||
|
||||
- **SC-007**: Per-agent webhook delivery rate does not exceed 60 per minute. Excess deliveries are queued and delivered in subsequent windows, not dropped.
|
||||
|
||||
- **SC-008**: When running in a Kubernetes cluster, an agent can register a K8s handler and a triggered message creates a Job within 10 seconds.
|
||||
|
||||
- **SC-009**: When NOT running in a Kubernetes cluster, the `register_k8s_handler` tool returns a clear error, and the system functions normally without K8s features.
|
||||
|
||||
- **SC-010**: The Web UI displays webhook delivery history and dead letters within 2 seconds of page load for agents with up to 10,000 delivery records (paginated).
|
||||
|
||||
- **SC-011**: All webhook and K8s handler operations produce trace entries that are visible in the existing trace viewer.
|
||||
|
||||
- **SC-012**: Webhook delivery does not block message sending. The sender receives a response immediately; delivery happens asynchronously.
|
||||
@@ -0,0 +1,270 @@
|
||||
# Tasks: Webhooks & Kubernetes Job Runner
|
||||
|
||||
**Input**: Design documents from `/specs/003-webhooks-k8s-runner/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/mcp-tools.md
|
||||
|
||||
**Tests**: Included — user explicitly requested test coverage.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: Add dependencies, create migration, create package scaffolding
|
||||
|
||||
- [X] T001 Add `golang.org/x/time` dependency via `go get golang.org/x/time`
|
||||
- [X] T002 Add `k8s.io/client-go` and `k8s.io/api` dependencies via `go get k8s.io/client-go k8s.io/api`
|
||||
- [X] T003 Create migration file `schema/009_webhooks.sql` with tables: webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs (copy SQL from data-model.md)
|
||||
- [X] T004 [P] Create package directory `internal/webhooks/` with empty `doc.go`
|
||||
- [X] T005 [P] Create package directory `internal/k8s/` with empty `doc.go`
|
||||
- [X] T006 [P] Create package directory `internal/dispatcher/` with empty `doc.go`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that ALL user stories depend on
|
||||
|
||||
**CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [X] T007 Implement `internal/webhooks/store.go` — SQLiteWebhookStore with methods: InsertWebhook, GetWebhookByID, GetWebhooksByAgent, GetActiveWebhooksByEvent, UpdateWebhookStatus, IncrementConsecutiveFailures, ResetConsecutiveFailures, DeleteWebhook, CountWebhooksByAgent, InsertDelivery, UpdateDeliveryStatus, GetPendingDeliveries, GetRetryableDeliveries, GetDeliveriesByAgent, GetDeadLetteredDeliveries, PurgeOldDeadLetters
|
||||
- [X] T008 Implement `internal/k8s/store.go` — SQLiteK8sStore with methods: InsertHandler, GetHandlerByID, GetHandlersByAgent, GetActiveHandlersByEvent, UpdateHandlerStatus, DeleteHandler, CountHandlersByAgent, InsertJobRun, UpdateJobRunStatus, GetJobRunsByHandler, GetJobRunsByAgent, GetJobRunByJobName
|
||||
- [X] T009 Implement `internal/dispatcher/dispatcher.go` — Event type definitions (MessageEvent with event type, message, depth, agent), EventDispatcher interface (Dispatch method), MultiDispatcher that fans out to registered dispatchers
|
||||
- [X] T010 Integrate migration 009_webhooks.sql into storage layer — ensure `internal/storage/migrations.go` picks up the new migration file via go:embed and applies it on startup
|
||||
|
||||
**Checkpoint**: Foundation ready — stores and dispatcher interface exist, migration applied
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Webhook Registration & Delivery (Priority: P1) MVP
|
||||
|
||||
**Goal**: Agent registers a webhook, sends a message, webhook is delivered with correct payload and headers
|
||||
|
||||
**Independent Test**: Register webhook for agent, send message to that agent, verify HTTP POST arrives with correct JSON payload and X-SynapBus-* headers
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T011 [P] [US1] Write test `internal/webhooks/store_test.go` — table-driven tests for InsertWebhook (success, duplicate URL, max 3 limit), GetWebhooksByAgent, DeleteWebhook, InsertDelivery, UpdateDeliveryStatus
|
||||
- [X] T012 [P] [US1] Write test `internal/webhooks/service_test.go` — table-driven tests for RegisterWebhook (validation, secret generation, max 3 check), ListWebhooks (URL masking), DeleteWebhook (ownership check)
|
||||
- [X] T013 [P] [US1] Write test `internal/webhooks/delivery_test.go` — test DeliveryEngine using httptest.NewServer: verify POST with correct Content-Type, X-SynapBus-Event, X-SynapBus-Delivery, X-SynapBus-Depth headers; verify payload structure matches contract; verify status transitions pending→delivered on HTTP 200
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T014 [US1] Implement `internal/webhooks/service.go` — WebhookService struct with fields: store, logger, config (AllowHTTP, AllowPrivateNetworks). Methods: RegisterWebhook (validate URL, check count, generate secret if needed, hash secret, store), ListWebhooks (mask URL paths), DeleteWebhook (ownership check), GetActiveWebhooksForEvent (match event type + agent name)
|
||||
- [X] T015 [US1] Implement `internal/webhooks/delivery.go` — DeliveryEngine struct with fields: store, httpClient, workerCount, deliveryChan, wg. Methods: Start (spawn worker goroutines), Stop (graceful shutdown), Enqueue (create delivery record, push to channel), deliverOne (build payload, sign, POST, update status). Worker pool pattern: N goroutines reading from buffered channel
|
||||
- [X] T016 [US1] Implement webhook MCP tools in `internal/mcp/webhook_tools.go` — WebhookToolRegistrar struct, RegisterAll method adding: register_webhook, list_webhooks, delete_webhook. Follow existing registrar pattern (mcp.NewTool with WithDescription, WithString, etc.)
|
||||
- [X] T017 [US1] Wire WebhookService and DeliveryEngine into `cmd/synapbus/main.go` — create WebhookStore, WebhookService, DeliveryEngine; start/stop engine in server lifecycle; register webhook MCP tools on MCP server
|
||||
- [X] T018 [US1] Integrate event dispatcher into messaging service — modify `internal/messaging/service.go` SendMessage to call dispatcher.Dispatch() after successful message insert, passing MessageEvent with event type, message data, and depth 0
|
||||
|
||||
**Checkpoint**: Core webhook flow works — register, send message, webhook fires with correct payload. Run `make test`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Security: HMAC, SSRF, Loop Detection (Priority: P1)
|
||||
|
||||
**Goal**: Webhook payloads signed with HMAC-SHA256, private IPs blocked, webhook loops terminate at depth 5
|
||||
|
||||
**Independent Test**: Verify signature computation matches expected HMAC; attempt registering private IP URLs (rejected); create chain of webhooks and verify depth cap
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T019 [P] [US2] Write test `internal/webhooks/security_test.go` — table-driven tests: ComputeSignature (known input → known HMAC output), ValidateURL (test each blocked IP range: 10.0.0.1, 172.16.0.1, 192.168.1.1, 127.0.0.1, ::1, fe80::1; test allowed public IPs; test HTTP vs HTTPS enforcement), IsPrivateIP (all RFC1918 ranges, loopback, link-local, IPv6 ULA)
|
||||
- [X] T020 [P] [US2] Write test `internal/webhooks/delivery_test.go` (extend) — test depth tracking: delivery with depth 4 succeeds, delivery with depth 5 is dead-lettered with "loop depth exceeded" reason; test DNS rebinding: mock DNS resolver returning private IP → delivery blocked
|
||||
- [X] T021 [P] [US2] Write test `internal/webhooks/delivery_test.go` (extend) — test redirect blocking: httptest server returning 301 → delivery marked as failed with "redirects not allowed"
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T022 [US2] Implement `internal/webhooks/security.go` — functions: ComputeHMACSignature(secret, payload []byte) string, ValidateWebhookURL(rawURL string, allowHTTP bool, allowPrivate bool) error, IsPrivateIP(ip net.IP) bool (check all RFC1918, loopback, link-local, IPv6 ULA ranges), NewSSRFSafeTransport(allowPrivate bool) *http.Transport (custom DialContext that resolves DNS and checks IP before connecting)
|
||||
- [X] T023 [US2] Integrate SSRF-safe HTTP client into DeliveryEngine — replace default http.Client with one using NewSSRFSafeTransport; add CheckRedirect func to block redirects; set timeouts (10s total, 5s TLS handshake, 5s response header)
|
||||
- [X] T024 [US2] Integrate depth tracking into delivery pipeline — in deliverOne: check depth >= 5 → dead-letter with reason; add X-SynapBus-Depth header to outgoing request; when message arrives via webhook-triggered action, extract depth from context and pass to dispatcher
|
||||
- [X] T025 [US2] Integrate URL validation into RegisterWebhook — call ValidateWebhookURL before storing; pass AllowHTTP and AllowPrivateNetworks config flags
|
||||
|
||||
**Checkpoint**: Security hardened — HMAC signatures verified, SSRF blocked, loops capped. Run `make test`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Retry, Rate Limiting, Dead Letters (Priority: P2)
|
||||
|
||||
**Goal**: Failed deliveries retry with exponential backoff, rate-limited per agent, dead letters queryable
|
||||
|
||||
**Independent Test**: Point webhook at mock server returning 500, verify 3 retries at correct intervals, delivery lands in dead letter queue. Test rate limiter: burst 61 deliveries, verify 61st is queued not dropped.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T026 [P] [US3] Write test `internal/webhooks/delivery_test.go` (extend) — test retry logic: mock server returns 503 → verify 3 attempts with backoff timing (use time mocking or short intervals for test); after 3 failures → status is dead_lettered; mock server returns 429 with Retry-After header → verify retry waits
|
||||
- [X] T027 [P] [US3] Write test `internal/webhooks/ratelimit_test.go` — test per-agent rate limiter: 60 deliveries allowed in rapid succession; 61st blocks until window; different agents have independent limits
|
||||
- [X] T028 [P] [US3] Write test `internal/webhooks/service_test.go` (extend) — test auto-disable: simulate 50 consecutive failures on a webhook → verify webhook status changes to disabled; verify no further deliveries attempted; test re-enable resets counter
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T029 [US3] Implement `internal/webhooks/ratelimit.go` — AgentRateLimiter struct with sync.Map of per-agent *rate.Limiter. Methods: Wait(ctx, agentName) error (get-or-create limiter, call limiter.Wait), Remove(agentName). Limiter config: rate.Every(time.Second), burst 60
|
||||
- [X] T030 [US3] Integrate retry logic into DeliveryEngine — in deliverOne: on HTTP 4xx/5xx, if attempts < maxAttempts, set status=retrying and next_retry_at; add retryLoop goroutine that polls GetRetryableDeliveries every second and re-enqueues due deliveries; handle 429 with Retry-After header
|
||||
- [X] T031 [US3] Integrate rate limiter into DeliveryEngine — before each delivery attempt, call rateLimiter.Wait(ctx, agentName); if context cancelled (shutdown), skip delivery
|
||||
- [X] T032 [US3] Implement auto-disable logic — after each failed delivery: call store.IncrementConsecutiveFailures; if consecutive_failures >= 50, call store.UpdateWebhookStatus(disabled); on successful delivery, call store.ResetConsecutiveFailures
|
||||
- [X] T033 [US3] Implement dead letter purge — background goroutine in DeliveryEngine that runs daily, calls store.PurgeOldDeadLetters(30 days)
|
||||
- [X] T034 [US3] Add REST API endpoints for dead letter management in `internal/api/webhook_handler.go` — GET /api/webhook-deliveries (list with filters), POST /api/webhook-deliveries/{id}/retry (reset delivery to pending), POST /api/webhooks/{id}/enable (re-enable + reset failures), POST /api/webhooks/{id}/disable
|
||||
- [X] T035 [US3] Wire webhook REST API routes into `internal/api/router.go` — add route group under auth middleware
|
||||
|
||||
**Checkpoint**: Retry/dead letter flow complete. Run `make test`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Kubernetes Job Runner (Priority: P2)
|
||||
|
||||
**Goal**: Register K8s handler, message triggers K8s Job creation, job status tracked
|
||||
|
||||
**Independent Test**: With mock K8s client, register handler, send message, verify Job spec matches contract, status updates on completion
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T036 [P] [US4] Write test `internal/k8s/store_test.go` — table-driven tests for InsertHandler (success, max 3 limit), GetHandlersByAgent, DeleteHandler, InsertJobRun, UpdateJobRunStatus, GetJobRunsByAgent
|
||||
- [X] T037 [P] [US4] Write test `internal/k8s/runner_test.go` — test with fake K8s clientset (k8s.io/client-go/kubernetes/fake): CreateJob (verify job name, namespace, env vars, resource limits, activeDeadlineSeconds, ttlSecondsAfterFinished), test NoopRunner returns error
|
||||
- [ ] T038 [P] [US4] Write test `internal/k8s/watcher_test.go` — test job status updates: simulate Job completion event → verify store updated to succeeded; simulate Job failure event → verify store updated to failed with reason
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T039 [US4] Implement `internal/k8s/runner.go` — JobRunner interface (CreateJob, IsAvailable, GetJobLogs). K8sJobRunner struct with clientset. NewJobRunner() tries rest.InClusterConfig(), returns K8sJobRunner on success. CreateJob builds batch/v1 Job spec with: name synapbus-{agent}-{msgID}, container with image/env/resources, restartPolicy=Never, activeDeadlineSeconds, ttlSecondsAfterFinished=3600, backoffLimit=0, label app.kubernetes.io/managed-by=synapbus. GetJobLogs proxies pod logs via clientset.
|
||||
- [X] T040 [US4] Implement `internal/k8s/noop.go` — NoopRunner that satisfies JobRunner interface, returns "not available" errors for all methods, IsAvailable() returns false
|
||||
- [ ] T041 [US4] Implement `internal/k8s/watcher.go` — JobWatcher struct using informers.NewSharedInformerFactory. Watches Jobs with label managed-by=synapbus. On Add/Update: extract job name → look up K8sJobRun → update status based on Job conditions (Active→running, Succeeded→succeeded, Failed→failed with reason from conditions)
|
||||
- [X] T042 [US4] Implement K8s handler MCP tools in `internal/mcp/webhook_tools.go` (extend) — add register_k8s_handler, list_k8s_handlers, delete_k8s_handler tools to WebhookToolRegistrar. register_k8s_handler checks runner.IsAvailable() first
|
||||
- [X] T043 [US4] Implement K8s dispatcher in `internal/k8s/dispatcher.go` (extend) — K8sDispatcher struct wrapping JobRunner + K8sStore. On Dispatch: find active handlers matching event, for each create K8sJobRun record then call runner.CreateJob
|
||||
- [X] T044 [US4] Wire K8s runner into `cmd/synapbus/main.go` — create K8sStore, JobRunner (try in-cluster, fallback to Noop), JobWatcher; register K8sDispatcher with MultiDispatcher; start/stop watcher in lifecycle
|
||||
- [X] T045 [US4] Add K8s REST API endpoints in `internal/api/webhook_handler.go` (extend) — GET /api/k8s-handlers, GET /api/k8s-job-runs, GET /api/k8s-job-runs/{id}/logs; wire routes
|
||||
|
||||
**Checkpoint**: K8s runner functional (with mock client in tests, real client in-cluster). Run `make test`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — @Mention Webhook Triggers (Priority: P3)
|
||||
|
||||
**Goal**: @mention in channel message triggers `message.mentioned` event for mentioned agents
|
||||
|
||||
**Independent Test**: Register webhook for message.mentioned, post channel message with @agentname, verify webhook fires with mention context
|
||||
|
||||
### Tests for User Story 5
|
||||
|
||||
- [X] T046 [P] [US5] Write test `internal/dispatcher/dispatcher_test.go` (extend) — test mention event: message body contains "@agent-a", dispatcher extracts mentioned agents and fires message.mentioned event to agent-a's webhooks; verify channel.message fires to all channel members with webhooks for that event; verify no double-delivery if agent has both message.mentioned and channel.message
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [X] T047 [US5] Implement mention extraction in `internal/dispatcher/dispatcher.go` — ExtractMentions(body string) []string function that finds @agent-name patterns. On channel message dispatch: identify mentioned agents, fire message.mentioned event to each mentioned agent's webhooks/handlers (in addition to channel.message to all members)
|
||||
- [X] T048 [US5] Integrate mention dispatch into messaging service — in SendMessage for channel messages: pass list of mentioned agents to event dispatcher alongside channel members
|
||||
|
||||
**Checkpoint**: @mention triggers work. Run `make test`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: User Story 6 — Web UI Management (Priority: P3)
|
||||
|
||||
**Goal**: Web UI pages for webhook management, delivery history, dead letters, K8s handler management
|
||||
|
||||
**Independent Test**: Navigate to agent's webhook page, see registered webhooks, delivery history, dead letters with retry button
|
||||
|
||||
### Implementation for User Story 6
|
||||
|
||||
- [X] T049 [P] [US6] Add webhook and K8s API methods to `web/src/lib/api/client.ts` — webhooks.list(agent), webhooks.enable(id), webhooks.disable(id), webhookDeliveries.list(agent, status, limit), webhookDeliveries.retry(id), k8sHandlers.list(agent), k8sJobRuns.list(agent, status, limit), k8sJobRuns.logs(id)
|
||||
- [X] T050 [P] [US6] Create `web/src/routes/agents/[name]/webhooks/+page.svelte` — list agent's webhooks (URL, events, status badge, failure count), enable/disable toggle, delete button; delivery history table (recent 50, status badge, timestamp); filter by status
|
||||
- [X] T051 [P] [US6] Create `web/src/routes/dead-letters/webhooks/+page.svelte` — list dead-lettered deliveries across all agents (for owner), show URL, event, error, attempts, timestamps; "Retry" button per delivery; pagination
|
||||
- [X] T052 [P] [US6] Create `web/src/routes/agents/[name]/k8s-handlers/+page.svelte` — list K8s handlers (image, events, namespace, resources, status); job run history table (job name, status badge, duration, timestamp); link to logs (fetched via API)
|
||||
- [X] T053 [US6] Add navigation links to webhook/K8s pages — update `web/src/lib/Sidebar.svelte` or agent detail page to include links to webhooks and K8s handlers pages
|
||||
- [X] T054 [US6] Build web assets with `make web` and verify embedded assets compile
|
||||
|
||||
**Checkpoint**: Web UI complete. Run `make web && make build`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Integration testing, documentation, cleanup
|
||||
|
||||
- [ ] T055 [P] Write integration test `internal/webhooks/integration_test.go` — end-to-end: register agent, register webhook (httptest server), send message, verify delivery arrives with correct signature, verify delivery record in DB
|
||||
- [ ] T056 [P] Write integration test `internal/k8s/integration_test.go` — end-to-end with fake clientset: register agent, register K8s handler, send message, verify Job created with correct spec
|
||||
- [X] T057 Add trace recording to all webhook/K8s MCP tool handlers in `internal/mcp/webhook_tools.go` — call tracer.Record() for register_webhook, delete_webhook, register_k8s_handler, delete_k8s_handler
|
||||
- [X] T058 Add startup re-queue logic to DeliveryEngine — on Start(), query DB for deliveries with status pending or retrying, re-enqueue them
|
||||
- [X] T059 Update MCP tool descriptions in `internal/mcp/webhook_tools.go` — ensure descriptions guide agent behavior per Constitution Principle II
|
||||
- [X] T060 Add new environment variables to `cmd/synapbus/main.go` — SYNAPBUS_WEBHOOK_WORKERS, SYNAPBUS_ALLOW_HTTP_WEBHOOKS, SYNAPBUS_ALLOW_PRIVATE_NETWORKS; bind to cobra flags
|
||||
- [ ] T061 Run quickstart.md validation — verify all steps in quickstart.md work with running server
|
||||
- [X] T062 Run full test suite `make test` and fix any failures
|
||||
- [X] T063 Run `make build` for cross-compilation check (CGO_ENABLED=0)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 (dependencies and migration)
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 (stores and dispatcher)
|
||||
- **US2 (Phase 4)**: Depends on Phase 3 (needs delivery engine to add security to)
|
||||
- **US3 (Phase 5)**: Depends on Phase 3 (needs delivery engine for retry/rate limit)
|
||||
- **US4 (Phase 6)**: Depends on Phase 2 (stores) — can run in parallel with US2/US3
|
||||
- **US5 (Phase 7)**: Depends on Phase 3 (needs dispatcher)
|
||||
- **US6 (Phase 8)**: Depends on Phases 5+6 (needs REST API endpoints from US3/US4)
|
||||
- **Polish (Phase 9)**: Depends on all user stories complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Foundation only — MVP
|
||||
- **US2 (P1)**: Depends on US1 (adds security to delivery engine)
|
||||
- **US3 (P2)**: Depends on US1 (adds retry/rate limiting to delivery engine)
|
||||
- **US4 (P2)**: Foundation only — independent of webhooks (parallel-capable with US2/US3)
|
||||
- **US5 (P3)**: Depends on US1 (extends dispatcher with mention events)
|
||||
- **US6 (P3)**: Depends on US3 + US4 (needs REST endpoints from both)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- **Phase 1**: T004, T005, T006 can all run in parallel (different directories)
|
||||
- **Phase 3**: T011, T012, T013 can all run in parallel (different test files)
|
||||
- **Phase 4**: T019, T020, T021 can all run in parallel (different test aspects)
|
||||
- **Phase 5**: T026, T027, T028 can all run in parallel (different test files)
|
||||
- **Phase 6**: T036, T037, T038 can all run in parallel (different test files)
|
||||
- **US4 can run in parallel with US2+US3** (independent packages)
|
||||
- **Phase 8**: T049, T050, T051, T052 can all run in parallel (different files)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 + 2 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational
|
||||
3. Complete Phase 3: US1 — Webhook registration + delivery
|
||||
4. Complete Phase 4: US2 — Security hardening
|
||||
5. **STOP and VALIDATE**: Test webhook flow end-to-end with HMAC verification
|
||||
6. This delivers: event-driven webhooks with security. Usable in production.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Foundation ready
|
||||
2. US1 + US2 → Secure webhook delivery (MVP!)
|
||||
3. US3 → Retry + dead letters → Production-ready reliability
|
||||
4. US4 → K8s Jobs → Cloud-native support
|
||||
5. US5 → @Mention triggers → Multi-agent collaboration
|
||||
6. US6 → Web UI → Human oversight dashboard
|
||||
7. Polish → Integration tests, docs, cleanup
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple agents:
|
||||
- Agent A: US1 → US2 → US3 (webhook pipeline)
|
||||
- Agent B: US4 (K8s runner, independent after foundation)
|
||||
- Agent C: US5 + US6 (after US1 complete)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story is independently completable and testable
|
||||
- Verify tests fail before implementing (TDD where applicable)
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Total: 63 tasks across 9 phases
|
||||
@@ -117,7 +117,7 @@ func setupEnv(t *testing.T) *testEnv {
|
||||
con := console.NewWithWriter(io.Discard)
|
||||
|
||||
// Create MCP server
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, con)
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, con, nil, nil)
|
||||
t.Cleanup(func() {
|
||||
mcpSrv.Shutdown(context.Background())
|
||||
})
|
||||
|
||||
@@ -125,6 +125,60 @@ export const deadLetters = {
|
||||
count: () => request<{ count: number }>('GET', '/api/dead-letters/count')
|
||||
};
|
||||
|
||||
// Webhooks
|
||||
export const webhooks = {
|
||||
list: (agent?: string) => {
|
||||
const qs = agent ? `?agent=${encodeURIComponent(agent)}` : '';
|
||||
return request<{ webhooks: any[]; count: number }>('GET', `/api/webhooks${qs}`);
|
||||
},
|
||||
enable: (id: number) =>
|
||||
request<{ status: string }>('POST', `/api/webhooks/${id}/enable`),
|
||||
disable: (id: number) =>
|
||||
request<{ status: string }>('POST', `/api/webhooks/${id}/disable`),
|
||||
deliveries: (webhookId: number, opts?: { status?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.status) qs.set('status', opts.status);
|
||||
if (opts?.limit) qs.set('limit', String(opts.limit));
|
||||
const q = qs.toString();
|
||||
return request<{ deliveries: any[]; count: number }>('GET', `/api/webhooks/${webhookId}/deliveries${q ? '?' + q : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Webhook Deliveries
|
||||
export const webhookDeliveries = {
|
||||
deadLetters: (opts?: { agent?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.agent) qs.set('agent', opts.agent);
|
||||
if (opts?.limit) qs.set('limit', String(opts.limit));
|
||||
const q = qs.toString();
|
||||
return request<{ deliveries: any[]; count: number }>('GET', `/api/deliveries/dead-letters${q ? '?' + q : ''}`);
|
||||
},
|
||||
retry: (id: number) =>
|
||||
request<{ status: string }>('POST', `/api/deliveries/${id}/retry`)
|
||||
};
|
||||
|
||||
// K8s Handlers
|
||||
export const k8sHandlers = {
|
||||
list: (agent?: string) => {
|
||||
const qs = agent ? `?agent=${encodeURIComponent(agent)}` : '';
|
||||
return request<{ handlers: any[]; count: number; k8s_available: boolean }>('GET', `/api/k8s/handlers${qs}`);
|
||||
}
|
||||
};
|
||||
|
||||
// K8s Job Runs
|
||||
export const k8sJobRuns = {
|
||||
list: (opts?: { agent?: string; status?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.agent) qs.set('agent', opts.agent);
|
||||
if (opts?.status) qs.set('status', opts.status);
|
||||
if (opts?.limit) qs.set('limit', String(opts.limit));
|
||||
const q = qs.toString();
|
||||
return request<{ job_runs: any[]; count: number }>('GET', `/api/k8s/job-runs${q ? '?' + q : ''}`);
|
||||
},
|
||||
logs: (id: number) =>
|
||||
request<{ logs: string }>('GET', `/api/k8s/job-runs/${id}/logs`)
|
||||
};
|
||||
|
||||
// API Keys
|
||||
export const apiKeys = {
|
||||
list: () => request<{ keys: any[] }>('GET', '/api/keys'),
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
{/each}
|
||||
<a
|
||||
href="/dead-letters"
|
||||
class="sidebar-item {isActive('/dead-letters') ? 'sidebar-item-active' : ''}"
|
||||
class="sidebar-item {isActive('/dead-letters') && $page.url.pathname === '/dead-letters' ? 'sidebar-item-active' : ''}"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z" />
|
||||
@@ -265,6 +265,15 @@
|
||||
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{deadLetterCount}</span>
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
href="/dead-letters/webhooks"
|
||||
class="sidebar-item {isActive('/dead-letters/webhooks') ? 'sidebar-item-active' : ''}"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Webhook Dead Letters
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -200,6 +200,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook & K8s Management Links -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Event Handlers</h2>
|
||||
</div>
|
||||
<div class="p-5 flex gap-3">
|
||||
<a href="/agents/{agentName}/webhooks" class="btn-secondary text-xs inline-flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Webhooks
|
||||
</a>
|
||||
<a href="/agents/{agentName}/k8s-handlers" class="btn-secondary text-xs inline-flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
K8s Handlers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Rights -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { k8sHandlers, k8sJobRuns } from '$lib/api/client';
|
||||
|
||||
let handlers = $state<any[]>([]);
|
||||
let jobRuns = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let k8sAvailable = $state(false);
|
||||
let selectedHandler = $state<number | null>(null);
|
||||
let logsContent = $state('');
|
||||
let logsJobId = $state<number | null>(null);
|
||||
let loadingLogs = $state(false);
|
||||
|
||||
let agentName = $derived($page.params.name);
|
||||
|
||||
async function loadHandlers() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await k8sHandlers.list(agentName);
|
||||
handlers = res.handlers || [];
|
||||
k8sAvailable = res.k8s_available ?? false;
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJobRuns() {
|
||||
try {
|
||||
const res = await k8sJobRuns.list({ agent: agentName, limit: 50 });
|
||||
jobRuns = res.job_runs || [];
|
||||
} catch {
|
||||
jobRuns = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function viewLogs(runId: number) {
|
||||
loadingLogs = true;
|
||||
logsJobId = runId;
|
||||
logsContent = '';
|
||||
try {
|
||||
const res = await k8sJobRuns.logs(runId);
|
||||
logsContent = res.logs || '(no output)';
|
||||
} catch (err: any) {
|
||||
logsContent = `Error: ${err.message || 'Failed to fetch logs'}`;
|
||||
} finally {
|
||||
loadingLogs = false;
|
||||
}
|
||||
}
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
loadHandlers();
|
||||
loadJobRuns();
|
||||
}
|
||||
});
|
||||
|
||||
function statusBadge(status: string): string {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-accent-green/20 text-accent-green';
|
||||
case 'disabled': return 'bg-accent-red/20 text-accent-red';
|
||||
case 'succeeded': return 'bg-accent-green/20 text-accent-green';
|
||||
case 'pending': return 'bg-accent-yellow/20 text-accent-yellow';
|
||||
case 'running': return 'bg-accent-blue/20 text-accent-blue';
|
||||
case 'failed': return 'bg-accent-red/20 text-accent-red';
|
||||
default: return 'bg-bg-tertiary text-text-secondary';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<a href="/agents/{agentName}" class="inline-flex items-center gap-1 text-xs text-text-link hover:underline mb-4">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to {agentName}
|
||||
</a>
|
||||
|
||||
<h1 class="text-lg font-bold text-text-primary font-display mb-4">K8s Handlers for @{agentName}</h1>
|
||||
|
||||
{#if !k8sAvailable && !loading}
|
||||
<div class="card p-4 mb-4 bg-accent-yellow/10 border-accent-yellow/20">
|
||||
<p class="text-xs text-accent-yellow">Kubernetes runner is not available. SynapBus is not running in a K8s cluster.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="card p-6">
|
||||
<div class="skeleton h-4 w-1/3 mb-3"></div>
|
||||
<div class="skeleton h-4 w-2/3"></div>
|
||||
</div>
|
||||
{:else if handlers.length === 0}
|
||||
<div class="card p-8 text-center text-text-secondary text-sm">
|
||||
No K8s handlers registered. Use the <code class="font-mono bg-bg-tertiary px-1 rounded">register_k8s_handler</code> MCP tool to add one.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3 mb-6">
|
||||
{#each handlers as h}
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge {statusBadge(h.status)}">{h.status}</span>
|
||||
<code class="text-xs font-mono text-text-primary">{h.image}</code>
|
||||
</div>
|
||||
<span class="text-[10px] text-text-secondary">ID: {h.id}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 text-[10px] text-text-secondary">
|
||||
<span>Events: {(h.events || []).join(', ')}</span>
|
||||
<span>Namespace: {h.namespace || 'default'}</span>
|
||||
{#if h.resources_memory}
|
||||
<span>Mem: {h.resources_memory}</span>
|
||||
{/if}
|
||||
{#if h.resources_cpu}
|
||||
<span>CPU: {h.resources_cpu}</span>
|
||||
{/if}
|
||||
<span>Timeout: {h.timeout_seconds}s</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Job Runs -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Job Runs</h2>
|
||||
</div>
|
||||
{#if jobRuns.length === 0}
|
||||
<div class="p-6 text-center text-text-secondary text-xs">No job runs yet</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-text-secondary">
|
||||
<th class="px-4 py-2 text-left font-medium">Job Name</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Status</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Namespace</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Message</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Started</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each jobRuns as run}
|
||||
<tr class="border-b border-border/50 hover:bg-bg-tertiary/30">
|
||||
<td class="px-4 py-2 font-mono">{run.job_name}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="badge {statusBadge(run.status)}">{run.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">{run.namespace}</td>
|
||||
<td class="px-4 py-2">#{run.message_id}</td>
|
||||
<td class="px-4 py-2 text-text-secondary">
|
||||
{run.started_at ? new Date(run.started_at).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
{#if run.status === 'succeeded' || run.status === 'failed'}
|
||||
<button
|
||||
class="btn-secondary text-[10px] px-2 py-0.5"
|
||||
onclick={() => viewLogs(run.id)}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Logs Modal -->
|
||||
{#if logsJobId !== null}
|
||||
<div class="card mt-4">
|
||||
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Job Logs</h2>
|
||||
<button class="text-xs text-text-secondary hover:text-text-primary" onclick={() => { logsJobId = null; logsContent = ''; }}>Close</button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{#if loadingLogs}
|
||||
<div class="skeleton h-20 w-full"></div>
|
||||
{:else}
|
||||
<pre class="text-xs font-mono bg-bg-primary p-3 rounded-lg border border-border overflow-x-auto max-h-96 whitespace-pre-wrap">{logsContent}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { webhooks as webhooksApi } from '$lib/api/client';
|
||||
|
||||
let hooksList = $state<any[]>([]);
|
||||
let deliveries = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let selectedWebhook = $state<number | null>(null);
|
||||
let statusFilter = $state('');
|
||||
let togglingId = $state<number | null>(null);
|
||||
|
||||
let agentName = $derived($page.params.name);
|
||||
|
||||
async function loadWebhooks() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await webhooksApi.list(agentName);
|
||||
hooksList = res.webhooks || [];
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeliveries(webhookId: number) {
|
||||
selectedWebhook = webhookId;
|
||||
try {
|
||||
const res = await webhooksApi.deliveries(webhookId, { status: statusFilter, limit: 50 });
|
||||
deliveries = res.deliveries || [];
|
||||
} catch {
|
||||
deliveries = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWebhook(id: number, currentStatus: string) {
|
||||
togglingId = id;
|
||||
try {
|
||||
if (currentStatus === 'active') {
|
||||
await webhooksApi.disable(id);
|
||||
} else {
|
||||
await webhooksApi.enable(id);
|
||||
}
|
||||
await loadWebhooks();
|
||||
if (selectedWebhook === id) await loadDeliveries(id);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to toggle webhook');
|
||||
} finally {
|
||||
togglingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
loadWebhooks();
|
||||
}
|
||||
});
|
||||
|
||||
function statusBadge(status: string): string {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-accent-green/20 text-accent-green';
|
||||
case 'disabled': return 'bg-accent-red/20 text-accent-red';
|
||||
case 'delivered': return 'bg-accent-green/20 text-accent-green';
|
||||
case 'pending': return 'bg-accent-yellow/20 text-accent-yellow';
|
||||
case 'retrying': return 'bg-accent-blue/20 text-accent-blue';
|
||||
case 'dead_lettered': return 'bg-accent-red/20 text-accent-red';
|
||||
default: return 'bg-bg-tertiary text-text-secondary';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<a href="/agents/{agentName}" class="inline-flex items-center gap-1 text-xs text-text-link hover:underline mb-4">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to {agentName}
|
||||
</a>
|
||||
|
||||
<h1 class="text-lg font-bold text-text-primary font-display mb-4">Webhooks for @{agentName}</h1>
|
||||
|
||||
{#if loading}
|
||||
<div class="card p-6">
|
||||
<div class="skeleton h-4 w-1/3 mb-3"></div>
|
||||
<div class="skeleton h-4 w-2/3"></div>
|
||||
</div>
|
||||
{:else if hooksList.length === 0}
|
||||
<div class="card p-8 text-center text-text-secondary text-sm">
|
||||
No webhooks registered for this agent. Use the <code class="font-mono bg-bg-tertiary px-1 rounded">register_webhook</code> MCP tool to add one.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3 mb-6">
|
||||
{#each hooksList as wh}
|
||||
<div class="card">
|
||||
<div class="p-4 flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="badge {statusBadge(wh.status)}">{wh.status}</span>
|
||||
<code class="text-xs font-mono text-text-primary truncate">{wh.url}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-[10px] text-text-secondary">
|
||||
<span>Events: {(wh.events || []).join(', ')}</span>
|
||||
{#if wh.consecutive_failures > 0}
|
||||
<span class="text-accent-red">Failures: {wh.consecutive_failures}</span>
|
||||
{/if}
|
||||
<span>Created: {new Date(wh.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
class="btn-secondary text-xs"
|
||||
onclick={() => loadDeliveries(wh.id)}
|
||||
class:ring-2={selectedWebhook === wh.id}
|
||||
class:ring-accent-blue={selectedWebhook === wh.id}
|
||||
>
|
||||
Deliveries
|
||||
</button>
|
||||
<button
|
||||
class="text-xs {wh.status === 'active' ? 'btn-danger' : 'btn-primary'}"
|
||||
onclick={() => toggleWebhook(wh.id, wh.status)}
|
||||
disabled={togglingId === wh.id}
|
||||
>
|
||||
{togglingId === wh.id ? '...' : wh.status === 'active' ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Delivery History -->
|
||||
{#if selectedWebhook !== null}
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Delivery History</h2>
|
||||
<select
|
||||
class="input text-xs w-auto"
|
||||
bind:value={statusFilter}
|
||||
onchange={() => loadDeliveries(selectedWebhook!)}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="retrying">Retrying</option>
|
||||
<option value="dead_lettered">Dead Letter</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if deliveries.length === 0}
|
||||
<div class="p-6 text-center text-text-secondary text-xs">No deliveries found</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-text-secondary">
|
||||
<th class="px-4 py-2 text-left font-medium">ID</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Event</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Status</th>
|
||||
<th class="px-4 py-2 text-left font-medium">HTTP</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Attempts</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Error</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each deliveries as d}
|
||||
<tr class="border-b border-border/50 hover:bg-bg-tertiary/30">
|
||||
<td class="px-4 py-2 font-mono">{d.id}</td>
|
||||
<td class="px-4 py-2">{d.event}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="badge {statusBadge(d.status)}">{d.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">{d.http_status || '-'}</td>
|
||||
<td class="px-4 py-2">{d.attempts}/{d.max_attempts}</td>
|
||||
<td class="px-4 py-2 text-accent-red max-w-[200px] truncate">{d.last_error || '-'}</td>
|
||||
<td class="px-4 py-2 text-text-secondary">{new Date(d.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { webhookDeliveries } from '$lib/api/client';
|
||||
|
||||
let deadLetters = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let retryingId = $state<number | null>(null);
|
||||
|
||||
async function loadDeadLetters() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await webhookDeliveries.deadLetters({ limit: 100 });
|
||||
deadLetters = res.deliveries || [];
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryDelivery(id: number) {
|
||||
retryingId = id;
|
||||
try {
|
||||
await webhookDeliveries.retry(id);
|
||||
await loadDeadLetters();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to retry delivery');
|
||||
} finally {
|
||||
retryingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
loadDeadLetters();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<a href="/dead-letters" class="inline-flex items-center gap-1 text-xs text-text-link hover:underline mb-4">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to dead letters
|
||||
</a>
|
||||
|
||||
<h1 class="text-lg font-bold text-text-primary font-display mb-4">Webhook Dead Letters</h1>
|
||||
<p class="text-xs text-text-secondary mb-4">Webhook deliveries that failed after all retry attempts.</p>
|
||||
|
||||
{#if loading}
|
||||
<div class="card p-6">
|
||||
<div class="skeleton h-4 w-1/3 mb-3"></div>
|
||||
<div class="skeleton h-4 w-2/3 mb-3"></div>
|
||||
<div class="skeleton h-4 w-1/2"></div>
|
||||
</div>
|
||||
{:else if deadLetters.length === 0}
|
||||
<div class="card p-8 text-center text-text-secondary text-sm">
|
||||
No dead-lettered webhook deliveries. All deliveries succeeded or are still retrying.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-text-secondary">
|
||||
<th class="px-4 py-2 text-left font-medium">ID</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Agent</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Event</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Message</th>
|
||||
<th class="px-4 py-2 text-left font-medium">HTTP</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Attempts</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Error</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Time</th>
|
||||
<th class="px-4 py-2 text-left font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each deadLetters as d}
|
||||
<tr class="border-b border-border/50 hover:bg-bg-tertiary/30">
|
||||
<td class="px-4 py-2 font-mono">{d.id}</td>
|
||||
<td class="px-4 py-2">@{d.agent_name}</td>
|
||||
<td class="px-4 py-2">{d.event}</td>
|
||||
<td class="px-4 py-2">#{d.message_id}</td>
|
||||
<td class="px-4 py-2">{d.http_status || '-'}</td>
|
||||
<td class="px-4 py-2">{d.attempts}/{d.max_attempts}</td>
|
||||
<td class="px-4 py-2 text-accent-red max-w-[250px] truncate" title={d.last_error}>
|
||||
{d.last_error || '-'}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-text-secondary">{new Date(d.created_at).toLocaleString()}</td>
|
||||
<td class="px-4 py-2">
|
||||
<button
|
||||
class="btn-primary text-[10px] px-2 py-0.5"
|
||||
onclick={() => retryDelivery(d.id)}
|
||||
disabled={retryingId === d.id}
|
||||
>
|
||||
{retryingId === d.id ? '...' : 'Retry'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user