From 42775df65fb869204bb72cd9e1cef7ac0a324485 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 14 Mar 2026 18:14:04 +0200 Subject: [PATCH] feat: webhooks & Kubernetes Job runner for event-driven agents - Webhook registration via MCP (register_webhook, list_webhooks, delete_webhook) - HMAC-SHA256 payload signing, SSRF-safe HTTP client, loop detection (depth 5) - 8-worker goroutine delivery pool with exponential backoff retry (1s/5s/30s) - Dead letter queue with auto-purge, auto-disable after 50 consecutive failures - Per-agent rate limiting (60 deliveries/min) - K8s Job runner (register_k8s_handler, list_k8s_handlers, delete_k8s_handler) - Auto-detect in-cluster via InClusterConfig, NoopRunner fallback - REST API for webhook deliveries, dead letters, K8s job runs and logs - Web UI: webhook management, K8s handler pages, dead letters view - MultiDispatcher fan-out pattern for webhook + K8s event dispatch - SQLite migration 009: webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs - 51 tests across 9 test packages, all passing Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + CLAUDE.md | 2 + cmd/synapbus/main.go | 57 +- deploy/helm/synapbus/values.yaml | 2 +- go.mod | 32 +- go.sum | 86 +- internal/api/k8s_handler.go | 169 ++++ internal/api/router.go | 31 + internal/api/webhooks_handler.go | 233 +++++ internal/dispatcher/dispatcher.go | 82 ++ internal/dispatcher/dispatcher_test.go | 210 +++++ internal/dispatcher/doc.go | 3 + internal/k8s/dispatcher.go | 112 +++ internal/k8s/doc.go | 4 + internal/k8s/noop.go | 30 + internal/k8s/runner.go | 237 +++++ internal/k8s/runner_test.go | 174 ++++ internal/k8s/service.go | 165 ++++ internal/k8s/service_test.go | 283 ++++++ internal/k8s/store.go | 470 ++++++++++ internal/k8s/store_test.go | 485 ++++++++++ internal/mcp/server.go | 10 + internal/mcp/server_test.go | 8 +- internal/mcp/webhook_tools.go | 316 +++++++ internal/messaging/service.go | 46 +- internal/storage/schema/009_webhooks.sql | 80 ++ internal/web/dist/index.html | 22 +- internal/webhooks/delivery.go | 405 +++++++++ internal/webhooks/delivery_test.go | 383 ++++++++ internal/webhooks/doc.go | 4 + internal/webhooks/ratelimit.go | 53 ++ internal/webhooks/ratelimit_test.go | 52 ++ internal/webhooks/security.go | 206 +++++ internal/webhooks/security_test.go | 194 ++++ internal/webhooks/service.go | 142 +++ internal/webhooks/service_test.go | 245 +++++ internal/webhooks/store.go | 574 ++++++++++++ internal/webhooks/store_test.go | 838 ++++++++++++++++++ schema/009_webhooks.sql | 80 ++ specs/001-production-readiness/research.md | 2 +- .../checklists/requirements.md | 37 + .../contracts/mcp-tools.md | 218 +++++ specs/003-webhooks-k8s-runner/data-model.md | 233 +++++ specs/003-webhooks-k8s-runner/plan.md | 108 +++ specs/003-webhooks-k8s-runner/quickstart.md | 153 ++++ specs/003-webhooks-k8s-runner/research.md | 158 ++++ specs/003-webhooks-k8s-runner/spec.md | 271 ++++++ specs/003-webhooks-k8s-runner/tasks.md | 270 ++++++ tests/integration/mcp_e2e_test.go | 2 +- web/src/lib/api/client.ts | 54 ++ web/src/lib/components/Sidebar.svelte | 11 +- web/src/routes/agents/[name]/+page.svelte | 21 + .../agents/[name]/k8s-handlers/+page.svelte | 192 ++++ .../agents/[name]/webhooks/+page.svelte | 187 ++++ .../routes/dead-letters/webhooks/+page.svelte | 108 +++ 55 files changed, 8507 insertions(+), 44 deletions(-) create mode 100644 internal/api/k8s_handler.go create mode 100644 internal/api/webhooks_handler.go create mode 100644 internal/dispatcher/dispatcher.go create mode 100644 internal/dispatcher/dispatcher_test.go create mode 100644 internal/dispatcher/doc.go create mode 100644 internal/k8s/dispatcher.go create mode 100644 internal/k8s/doc.go create mode 100644 internal/k8s/noop.go create mode 100644 internal/k8s/runner.go create mode 100644 internal/k8s/runner_test.go create mode 100644 internal/k8s/service.go create mode 100644 internal/k8s/service_test.go create mode 100644 internal/k8s/store.go create mode 100644 internal/k8s/store_test.go create mode 100644 internal/mcp/webhook_tools.go create mode 100644 internal/storage/schema/009_webhooks.sql create mode 100644 internal/webhooks/delivery.go create mode 100644 internal/webhooks/delivery_test.go create mode 100644 internal/webhooks/doc.go create mode 100644 internal/webhooks/ratelimit.go create mode 100644 internal/webhooks/ratelimit_test.go create mode 100644 internal/webhooks/security.go create mode 100644 internal/webhooks/security_test.go create mode 100644 internal/webhooks/service.go create mode 100644 internal/webhooks/service_test.go create mode 100644 internal/webhooks/store.go create mode 100644 internal/webhooks/store_test.go create mode 100644 schema/009_webhooks.sql create mode 100644 specs/003-webhooks-k8s-runner/checklists/requirements.md create mode 100644 specs/003-webhooks-k8s-runner/contracts/mcp-tools.md create mode 100644 specs/003-webhooks-k8s-runner/data-model.md create mode 100644 specs/003-webhooks-k8s-runner/plan.md create mode 100644 specs/003-webhooks-k8s-runner/quickstart.md create mode 100644 specs/003-webhooks-k8s-runner/research.md create mode 100644 specs/003-webhooks-k8s-runner/spec.md create mode 100644 specs/003-webhooks-k8s-runner/tasks.md create mode 100644 web/src/routes/agents/[name]/k8s-handlers/+page.svelte create mode 100644 web/src/routes/agents/[name]/webhooks/+page.svelte create mode 100644 web/src/routes/dead-letters/webhooks/+page.svelte diff --git a/.gitignore b/.gitignore index 3bf592d..f39b27e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ vendor/ # Data directory data/ +data-*/ # Node / Svelte web/node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 19077c8..c70a5f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/cmd/synapbus/main.go b/cmd/synapbus/main.go index 1aad974..feab508 100644 --- a/cmd/synapbus/main.go +++ b/cmd/synapbus/main.go @@ -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 @@ -530,6 +566,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() diff --git a/deploy/helm/synapbus/values.yaml b/deploy/helm/synapbus/values.yaml index d2a1524..49bc2d9 100644 --- a/deploy/helm/synapbus/values.yaml +++ b/deploy/helm/synapbus/values.yaml @@ -1,7 +1,7 @@ replicaCount: 1 image: - repository: ghcr.io/smart-mcp-proxy/synapbus + repository: ghcr.io/synapbus/synapbus pullPolicy: IfNotPresent tag: "latest" diff --git a/go.mod b/go.mod index cc90bce..bad887b 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index fe339d6..bdd400a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/k8s_handler.go b/internal/api/k8s_handler.go new file mode 100644 index 0000000..13e9ac7 --- /dev/null +++ b/internal/api/k8s_handler.go @@ -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 +} diff --git a/internal/api/router.go b/internal/api/router.go index 6a4b106..6c10671 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/webhooks_handler.go b/internal/api/webhooks_handler.go new file mode 100644 index 0000000..4c768d6 --- /dev/null +++ b/internal/api/webhooks_handler.go @@ -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 +} diff --git a/internal/dispatcher/dispatcher.go b/internal/dispatcher/dispatcher.go new file mode 100644 index 0000000..9ee614e --- /dev/null +++ b/internal/dispatcher/dispatcher.go @@ -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 +} diff --git a/internal/dispatcher/dispatcher_test.go b/internal/dispatcher/dispatcher_test.go new file mode 100644 index 0000000..6fdc717 --- /dev/null +++ b/internal/dispatcher/dispatcher_test.go @@ -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]) + } + } + }) + } +} diff --git a/internal/dispatcher/doc.go b/internal/dispatcher/doc.go new file mode 100644 index 0000000..ab813d6 --- /dev/null +++ b/internal/dispatcher/doc.go @@ -0,0 +1,3 @@ +// Package dispatcher provides the event dispatch interface that decouples +// message sending from delivery mechanisms (webhooks, K8s Jobs). +package dispatcher diff --git a/internal/k8s/dispatcher.go b/internal/k8s/dispatcher.go new file mode 100644 index 0000000..a05f9eb --- /dev/null +++ b/internal/k8s/dispatcher.go @@ -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 +} diff --git a/internal/k8s/doc.go b/internal/k8s/doc.go new file mode 100644 index 0000000..3d52c7a --- /dev/null +++ b/internal/k8s/doc.go @@ -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 diff --git a/internal/k8s/noop.go b/internal/k8s/noop.go new file mode 100644 index 0000000..70bcb72 --- /dev/null +++ b/internal/k8s/noop.go @@ -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)") +} diff --git a/internal/k8s/runner.go b/internal/k8s/runner.go new file mode 100644 index 0000000..1093ddc --- /dev/null +++ b/internal/k8s/runner.go @@ -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] +} diff --git a/internal/k8s/runner_test.go b/internal/k8s/runner_test.go new file mode 100644 index 0000000..df193c3 --- /dev/null +++ b/internal/k8s/runner_test.go @@ -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") + } + }) +} diff --git a/internal/k8s/service.go b/internal/k8s/service.go new file mode 100644 index 0000000..abbd34d --- /dev/null +++ b/internal/k8s/service.go @@ -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 +} diff --git a/internal/k8s/service_test.go b/internal/k8s/service_test.go new file mode 100644 index 0000000..02d9556 --- /dev/null +++ b/internal/k8s/service_test.go @@ -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") + } + }) +} diff --git a/internal/k8s/store.go b/internal/k8s/store.go new file mode 100644 index 0000000..7c8ee05 --- /dev/null +++ b/internal/k8s/store.go @@ -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 +} diff --git a/internal/k8s/store_test.go b/internal/k8s/store_test.go new file mode 100644 index 0000000..624cb68 --- /dev/null +++ b/internal/k8s/store_test.go @@ -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) + } + }) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index cb785fd..a83236b 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -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 { diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index c4911ee..8e15633 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -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()) diff --git a/internal/mcp/webhook_tools.go b/internal/mcp/webhook_tools.go new file mode 100644 index 0000000..a3ed69d --- /dev/null +++ b/internal/mcp/webhook_tools.go @@ -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 +} diff --git a/internal/messaging/service.go b/internal/messaging/service.go index 88ca6d5..f35ebe9 100644 --- a/internal/messaging/service.go +++ b/internal/messaging/service.go @@ -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 } diff --git a/internal/storage/schema/009_webhooks.sql b/internal/storage/schema/009_webhooks.sql new file mode 100644 index 0000000..db857a5 --- /dev/null +++ b/internal/storage/schema/009_webhooks.sql @@ -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); diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index a0f4576..b183fda 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -8,29 +8,29 @@ - - - - - - - - + + + + + + + +
+ +
+ + + + + Back to {agentName} + + +

K8s Handlers for @{agentName}

+ + {#if !k8sAvailable && !loading} +
+

Kubernetes runner is not available. SynapBus is not running in a K8s cluster.

+
+ {/if} + + {#if loading} +
+
+
+
+ {:else if handlers.length === 0} +
+ No K8s handlers registered. Use the register_k8s_handler MCP tool to add one. +
+ {:else} +
+ {#each handlers as h} +
+
+
+ {h.status} + {h.image} +
+ ID: {h.id} +
+
+ Events: {(h.events || []).join(', ')} + Namespace: {h.namespace || 'default'} + {#if h.resources_memory} + Mem: {h.resources_memory} + {/if} + {#if h.resources_cpu} + CPU: {h.resources_cpu} + {/if} + Timeout: {h.timeout_seconds}s +
+
+ {/each} +
+ {/if} + + +
+
+

Job Runs

+
+ {#if jobRuns.length === 0} +
No job runs yet
+ {:else} +
+ + + + + + + + + + + + + {#each jobRuns as run} + + + + + + + + + {/each} + +
Job NameStatusNamespaceMessageStartedActions
{run.job_name} + {run.status} + {run.namespace}#{run.message_id} + {run.started_at ? new Date(run.started_at).toLocaleString() : '-'} + + {#if run.status === 'succeeded' || run.status === 'failed'} + + {/if} +
+
+ {/if} +
+ + + {#if logsJobId !== null} +
+
+

Job Logs

+ +
+
+ {#if loadingLogs} +
+ {:else} +
{logsContent}
+ {/if} +
+
+ {/if} +
diff --git a/web/src/routes/agents/[name]/webhooks/+page.svelte b/web/src/routes/agents/[name]/webhooks/+page.svelte new file mode 100644 index 0000000..ed61c46 --- /dev/null +++ b/web/src/routes/agents/[name]/webhooks/+page.svelte @@ -0,0 +1,187 @@ + + +
+ + + + + Back to {agentName} + + +

Webhooks for @{agentName}

+ + {#if loading} +
+
+
+
+ {:else if hooksList.length === 0} +
+ No webhooks registered for this agent. Use the register_webhook MCP tool to add one. +
+ {:else} +
+ {#each hooksList as wh} +
+
+
+
+ {wh.status} + {wh.url} +
+
+ Events: {(wh.events || []).join(', ')} + {#if wh.consecutive_failures > 0} + Failures: {wh.consecutive_failures} + {/if} + Created: {new Date(wh.created_at).toLocaleDateString()} +
+
+
+ + +
+
+
+ {/each} +
+ + + {#if selectedWebhook !== null} +
+
+

Delivery History

+ +
+ {#if deliveries.length === 0} +
No deliveries found
+ {:else} +
+ + + + + + + + + + + + + + {#each deliveries as d} + + + + + + + + + + {/each} + +
IDEventStatusHTTPAttemptsErrorTime
{d.id}{d.event} + {d.status} + {d.http_status || '-'}{d.attempts}/{d.max_attempts}{d.last_error || '-'}{new Date(d.created_at).toLocaleString()}
+
+ {/if} +
+ {/if} + {/if} +
diff --git a/web/src/routes/dead-letters/webhooks/+page.svelte b/web/src/routes/dead-letters/webhooks/+page.svelte new file mode 100644 index 0000000..e5264ae --- /dev/null +++ b/web/src/routes/dead-letters/webhooks/+page.svelte @@ -0,0 +1,108 @@ + + +
+ + + + + Back to dead letters + + +

Webhook Dead Letters

+

Webhook deliveries that failed after all retry attempts.

+ + {#if loading} +
+
+
+
+
+ {:else if deadLetters.length === 0} +
+ No dead-lettered webhook deliveries. All deliveries succeeded or are still retrying. +
+ {:else} +
+
+ + + + + + + + + + + + + + + + {#each deadLetters as d} + + + + + + + + + + + + {/each} + +
IDAgentEventMessageHTTPAttemptsErrorTimeActions
{d.id}@{d.agent_name}{d.event}#{d.message_id}{d.http_status || '-'}{d.attempts}/{d.max_attempts} + {d.last_error || '-'} + {new Date(d.created_at).toLocaleString()} + +
+
+
+ {/if} +