Compare commits
@@ -8,6 +8,7 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.25"
|
||||
@@ -217,3 +218,28 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
mcp-registry:
|
||||
name: Publish to MCP Registry
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install mcp-publisher
|
||||
run: |
|
||||
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher
|
||||
|
||||
- name: Authenticate to MCP Registry
|
||||
run: ./mcp-publisher login github-oidc
|
||||
|
||||
- name: Update version in server.json
|
||||
run: |
|
||||
jq --arg v "${{ steps.version.outputs.VERSION }}" '.version = $v' server.json > server.tmp && mv server.tmp server.json
|
||||
|
||||
- name: Publish to MCP Registry
|
||||
run: ./mcp-publisher publish
|
||||
|
||||
@@ -100,6 +100,9 @@ make lint # Run linters
|
||||
- SQLite (modernc.org/sqlite, pure Go) — single DB file in `--data` directory (004-embeddings-retention-inbox)
|
||||
- Go 1.25+ (per go.mod) + spf13/cobra (CLI), go-chi/chi (HTTP), mark3labs/mcp-go (MCP) (006-admin-cli-docker-fixes)
|
||||
- modernc.org/sqlite (pure Go, zero CGO) (006-admin-cli-docker-fixes)
|
||||
- Go 1.25+ (per go.mod) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), ory/fosite (OAuth), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors). NEW: coreos/go-oidc/v3 (OIDC), golang.org/x/oauth2 (OAuth client) (007-platform-features-bundle)
|
||||
- Go 1.25+ (backend), SvelteKit 2 + Svelte 5 (frontend), SvelteKit (website) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), SherClockHolmes/webpush-go (push notifications — NEW) (008-webui-pwa-analytics)
|
||||
- SQLite (existing DB, 1 new migration for push_subscriptions), localStorage (font size) (008-webui-pwa-analytics)
|
||||
|
||||
## 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)
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN apk add --no-cache ca-certificates tzdata && touch /.dockerenv
|
||||
COPY --from=go-builder /synapbus /synapbus
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
+45
-79
@@ -1,96 +1,62 @@
|
||||
# Autonomous Implementation Summary
|
||||
# Autonomous Execution Summary: SynapBus v0.7.0
|
||||
|
||||
**Feature**: Admin CLI & Docker Fixes
|
||||
**Branch**: `006-admin-cli-docker-fixes`
|
||||
**Date**: 2026-03-15
|
||||
**Status**: COMPLETE — 8 of 8 tasks implemented, all tests pass, binary builds
|
||||
**Date**: 2026-03-17
|
||||
**Branch**: `008-webui-pwa-analytics`
|
||||
**Status**: Complete — all tests pass, binary builds, endpoints verified
|
||||
|
||||
## What Was Built
|
||||
## Features Implemented
|
||||
|
||||
### 1. Alpine Docker Base Image (T06)
|
||||
### 1. Analytics Dashboard (P1)
|
||||
- 4 new REST endpoints (`/api/analytics/timeline`, `/top-agents`, `/top-channels`, `/summary`)
|
||||
- SVG bar chart (`AnalyticsChart.svelte`), ranked list (`TopList.svelte`), redesigned dashboard with stat cards and time span selector (1h, 4h, 24h, 7d, 1month)
|
||||
|
||||
**Problem**: `scratch` base image has no shell — `kubectl exec` into the pod can't run admin CLI commands.
|
||||
### 2. PWA Conversion (P1)
|
||||
- PWA manifest (`manifest.json`), service worker (`sw.js`), SVG icon
|
||||
- Cache-first for static assets, network-only for API, push notification handling
|
||||
|
||||
**Solution**: Changed `FROM scratch` to `FROM alpine:3.19` in the runtime stage. Alpine provides `/bin/sh` and a working process environment. TLS certs and timezone data are now installed via `apk` instead of copied from the builder stage.
|
||||
### 3. Push Notifications (P1)
|
||||
- `internal/push/` package — VAPID key generation, Web Push sending, SQLite subscription store
|
||||
- API: `POST/DELETE /api/push/subscribe`, `GET /api/push/vapid-key`
|
||||
- Push toggle in Settings, migration `012_push_subscriptions.sql`
|
||||
|
||||
**File Modified**: `Dockerfile`
|
||||
### 4. Auto-Resizing Textarea (P1)
|
||||
- ComposeForm textarea auto-grows 3→12 lines, then scrollbar. Resets on send.
|
||||
|
||||
### 2. `synapbus channels create` CLI Command (T02, T04)
|
||||
### 5. Smart Mention/Channel Highlighting (P2)
|
||||
- Entities store caches agents/channels. MessageBody validates @mentions and #channels:
|
||||
existing → link, deleted → "inactive" badge, unknown → plain text. Handles email/issue number edge cases.
|
||||
|
||||
**Problem**: No CLI command to create channels — had to use REST API with session cookies.
|
||||
### 6. Editable Agent Display Name (P2)
|
||||
- Inline edit on agent detail page (click → edit, Enter → save, Escape → cancel)
|
||||
|
||||
**Solution**: Added `channels.create` admin socket handler and `synapbus channels create` cobra command:
|
||||
- `--name` (required): Channel name
|
||||
- `--description` (optional): Channel description
|
||||
- Creates channel via the channel service with `created_by: "system"`, type `"standard"`
|
||||
- Returns channel details as JSON
|
||||
### 7. Editable Human Display Name (P2)
|
||||
- `PUT /api/auth/profile` endpoint, `UpdateDisplayName` in UserStore, Settings page field
|
||||
|
||||
**Files Modified**: `cmd/synapbus/admin.go`, `internal/admin/socket.go`
|
||||
### 8. Font Size Preference (P3)
|
||||
- fontSize store (12–24px, 2px steps), -/+ controls in Settings, persisted in localStorage
|
||||
|
||||
### 3. `synapbus channels join` CLI Command (T03, T05)
|
||||
### 9. Version Display (P3)
|
||||
- `GET /api/version` endpoint, version footer in layout linked to GitHub repo
|
||||
|
||||
**Problem**: No CLI command to add agents to channels.
|
||||
### 10. MCP Prompts (P2)
|
||||
- 4 prompts: daily-digest, agent-health-check, channel-overview, debug-agent
|
||||
- `internal/mcp/prompts.go` registered in server.go
|
||||
|
||||
**Solution**: Added `channels.join` admin socket handler and `synapbus channels join` cobra command:
|
||||
- `--channel` (required): Channel name to join
|
||||
- `--agent` (required): Agent name to add
|
||||
- Looks up channel by name, calls `JoinChannel` (idempotent)
|
||||
- Reports `"joined"` or `"already_member"` status
|
||||
### 11. Website Update (P3)
|
||||
- Updated hero/features messaging at ~/repos/synapbus-website/ for individual/small-team positioning
|
||||
|
||||
**Files Modified**: `cmd/synapbus/admin.go`, `internal/admin/socket.go`
|
||||
## Test Results
|
||||
|
||||
### 4. Absolute Default Socket Path (T01)
|
||||
All 24 Go packages PASS. All API endpoints verified via curl. Web UI builds successfully. Binary compiles with CGO_ENABLED=0.
|
||||
|
||||
**Problem**: Default `./data/synapbus.sock` is confusing in containers where CWD varies.
|
||||
## New Files
|
||||
|
||||
**Solution**: Changed default socket path from `./data/synapbus.sock` to `/data/synapbus.sock` in both the `--socket` flag definition and the `SYNAPBUS_SOCKET` env var comparison.
|
||||
|
||||
**File Modified**: `cmd/synapbus/admin.go`
|
||||
|
||||
## Tests Added (T07)
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `TestChannelsCreateCommandRegistered` | Verifies `channels create` subcommand exists |
|
||||
| `TestChannelsCreateRequiredFlags` | Verifies `--name` is required, `--description` is optional |
|
||||
| `TestChannelsJoinCommandRegistered` | Verifies `channels join` subcommand exists |
|
||||
| `TestChannelsJoinRequiredFlags` | Verifies `--channel` and `--agent` are both required |
|
||||
| `TestDefaultSocketPath` | Verifies default is `/data/synapbus.sock` |
|
||||
|
||||
## Verification Results (T08)
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go build ./...` | PASS |
|
||||
| `go test ./...` | ALL PASS (24 packages, 0 failures) |
|
||||
| Zero CGO | Confirmed (CGO_ENABLED=0 in Dockerfile) |
|
||||
| No regressions | All 14 existing CLI tests still pass |
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `Dockerfile` | `FROM scratch` → `FROM alpine:3.19` + `apk add --no-cache ca-certificates tzdata` |
|
||||
| `cmd/synapbus/admin.go` | Default socket `/data/synapbus.sock`, `channels create` + `channels join` commands |
|
||||
| `cmd/synapbus/admin_test.go` | 5 new tests for commands, flags, and default socket |
|
||||
| `internal/admin/socket.go` | `channels.create` + `channels.join` handlers, `channels` import |
|
||||
|
||||
## CLI Commands Added
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `synapbus channels create --name X [--description Y]` | Create a new channel |
|
||||
| `synapbus channels join --channel X --agent Y` | Add an agent to a channel |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# In Kubernetes (now works with alpine base)
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels create --name news-feed --description "News feed"
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels join --channel news-feed --agent research-mcpproxy
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list
|
||||
|
||||
# Local development
|
||||
synapbus --socket ./data/synapbus.sock channels create --name test-channel
|
||||
synapbus --socket ./data/synapbus.sock channels join --channel test-channel --agent my-agent
|
||||
```
|
||||
- `internal/api/analytics_handler.go` + test
|
||||
- `internal/api/version_handler.go` + test
|
||||
- `internal/api/push_handler.go`
|
||||
- `internal/push/service.go` + test, `store.go` + test
|
||||
- `internal/mcp/prompts.go` + test
|
||||
- `schema/012_push_subscriptions.sql`
|
||||
- `web/src/lib/components/AnalyticsChart.svelte`, `TopList.svelte`
|
||||
- `web/src/lib/stores/fontSize.ts`, `entities.ts`
|
||||
- `web/static/manifest.json`, `sw.js`, `icons/icon.svg`
|
||||
|
||||
+27
-3
@@ -17,7 +17,7 @@ var adminSocket string
|
||||
// adminRequest sends a command over the Unix socket and returns the parsed response.
|
||||
func adminRequest(command string, args interface{}) (map[string]interface{}, error) {
|
||||
socket := adminSocket
|
||||
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "/data/synapbus.sock" {
|
||||
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "/tmp/synapbus.sock" {
|
||||
socket = s
|
||||
}
|
||||
|
||||
@@ -308,7 +308,31 @@ func addAdminCommands(rootCmd *cobra.Command) {
|
||||
agentRevokeKeyCmd.Flags().StringVar(&agentRevokeKeyName, "name", "", "Agent name")
|
||||
agentRevokeKeyCmd.MarkFlagRequired("name")
|
||||
|
||||
agentCmd.AddCommand(agentListCmd, agentCreateCmd, agentDeleteCmd, agentRevokeKeyCmd)
|
||||
var (
|
||||
agentUpdateCapsName string
|
||||
agentUpdateCapsJSON string
|
||||
)
|
||||
agentUpdateCapsCmd := &cobra.Command{
|
||||
Use: "update-capabilities",
|
||||
Short: "Update an agent's capabilities JSON",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
resp, err := adminRequest("agent.update_capabilities", map[string]interface{}{
|
||||
"name": agentUpdateCapsName,
|
||||
"capabilities": json.RawMessage(agentUpdateCapsJSON),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(resp["data"])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
agentUpdateCapsCmd.Flags().StringVar(&agentUpdateCapsName, "name", "", "Agent name")
|
||||
agentUpdateCapsCmd.Flags().StringVar(&agentUpdateCapsJSON, "capabilities", "", "Capabilities JSON (e.g. '{\"role\":\"researcher\"}')")
|
||||
agentUpdateCapsCmd.MarkFlagRequired("name")
|
||||
agentUpdateCapsCmd.MarkFlagRequired("capabilities")
|
||||
|
||||
agentCmd.AddCommand(agentListCmd, agentCreateCmd, agentDeleteCmd, agentRevokeKeyCmd, agentUpdateCapsCmd)
|
||||
|
||||
// ----- audit commands -----
|
||||
auditCmd := &cobra.Command{
|
||||
@@ -966,7 +990,7 @@ func addAdminCommands(rootCmd *cobra.Command) {
|
||||
attachmentsCmd.AddCommand(attachmentsGCCmd)
|
||||
|
||||
// ----- add persistent flag and commands to root -----
|
||||
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "/data/synapbus.sock", "Path to admin Unix socket")
|
||||
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "/tmp/synapbus.sock", "Path to admin Unix socket")
|
||||
|
||||
rootCmd.AddCommand(userCmd, agentCmd, auditCmd, backupCmd, messagesCmd, channelsCmd, conversationsCmd, embeddingsCmd, dbCmd, retentionCmd, webhookCmd, k8sCmd, attachmentsCmd)
|
||||
}
|
||||
|
||||
@@ -270,8 +270,8 @@ func TestDefaultSocketPath(t *testing.T) {
|
||||
if f == nil {
|
||||
t.Fatal("--socket persistent flag not found")
|
||||
}
|
||||
if f.DefValue != "/data/synapbus.sock" {
|
||||
t.Errorf("default socket path = %q, want %q", f.DefValue, "/data/synapbus.sock")
|
||||
if f.DefValue != "/tmp/synapbus.sock" {
|
||||
t.Errorf("default socket path = %q, want %q", f.DefValue, "/tmp/synapbus.sock")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+142
-2
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/a2a"
|
||||
"github.com/synapbus/synapbus/internal/actions"
|
||||
"github.com/synapbus/synapbus/internal/admin"
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
@@ -30,6 +31,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/apikeys"
|
||||
"github.com/synapbus/synapbus/internal/attachments"
|
||||
"github.com/synapbus/synapbus/internal/auth"
|
||||
"github.com/synapbus/synapbus/internal/auth/idp"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/console"
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
@@ -42,6 +44,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/search"
|
||||
"github.com/synapbus/synapbus/internal/search/embedding"
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
"github.com/synapbus/synapbus/internal/push"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"github.com/synapbus/synapbus/internal/web"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
@@ -161,7 +164,13 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
messageRetention = mr
|
||||
}
|
||||
if adminSocketPath == "" {
|
||||
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
|
||||
// Default to /tmp in containers — PVC-backed filesystems (NFS, Ceph,
|
||||
// EBS CSI) often don't support Unix domain sockets.
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
adminSocketPath = "/tmp/synapbus.sock"
|
||||
} else {
|
||||
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
|
||||
}
|
||||
}
|
||||
|
||||
// Configure slog with JSON handler writing to stderr (stdout is for console output)
|
||||
@@ -299,6 +308,13 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
// Wire agent lister into auth handlers for OAuth authorize page
|
||||
authHandlers.SetAgentLister(&agentListerAdapter{agentService: agentService})
|
||||
|
||||
// Initialize external identity providers (GitHub, Google, Azure AD)
|
||||
baseURL := authCfg.IssuerURL
|
||||
if baseURL == "" {
|
||||
baseURL = fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
idpProviders := idp.LoadConfig(baseURL)
|
||||
|
||||
// Register default MCP OAuth client if it doesn't already exist (T016)
|
||||
ensureDefaultMCPClient(ctx, db.DB, authCfg.BcryptCost)
|
||||
|
||||
@@ -389,6 +405,9 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
embPipeline = search.NewPipeline(embProvider, embStore, vectorIndex, searchCfg)
|
||||
embPipeline.Start(ctx)
|
||||
|
||||
// Wire pipeline into messaging so new messages auto-enqueue
|
||||
msgService.SetEmbeddingNotifier(embPipeline)
|
||||
|
||||
// Create search service with semantic support
|
||||
searchService = search.NewService(db.DB, embProvider, vectorIndex, msgService)
|
||||
slog.Info("semantic search enabled",
|
||||
@@ -468,6 +487,17 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
slog.Info("message retention disabled")
|
||||
}
|
||||
|
||||
// Start stalemate worker (message acknowledgment enforcement)
|
||||
stalemateConfig := messaging.ParseStalemateConfig()
|
||||
stalemateWorker := messaging.NewStalemateWorker(db.DB, msgService, &channelLookupAdapter{channelService: channelService}, stalemateConfig)
|
||||
stalemateWorker.Start()
|
||||
slog.Info("stalemate worker started",
|
||||
"processing_timeout", stalemateConfig.ProcessingTimeout.String(),
|
||||
"reminder_after", stalemateConfig.ReminderAfter.String(),
|
||||
"escalate_after", stalemateConfig.EscalateAfter.String(),
|
||||
"interval", stalemateConfig.Interval.String(),
|
||||
)
|
||||
|
||||
// Create health checker
|
||||
healthChecker := health.NewChecker(db.DB, version)
|
||||
|
||||
@@ -505,9 +535,34 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
r.Post("/auth/register", withHumanAgent(authHandlers.HandleRegister, userStore, agentService, channelService))
|
||||
r.Post("/auth/login", withHumanAgent(authHandlers.HandleLogin, userStore, agentService, channelService))
|
||||
|
||||
// External identity provider endpoints (public)
|
||||
if len(idpProviders) > 0 {
|
||||
idpStore := idp.NewUserIdentityStore(db.DB)
|
||||
idpAgentAdapter := &idpAgentProvisioner{agentService: agentService, channelService: channelService}
|
||||
idpHandlers := idp.NewHandlers(idpProviders, idpStore, userStore, sessionStore, idpAgentAdapter)
|
||||
r.Get("/auth/providers", idpHandlers.HandleListProviders)
|
||||
r.Get("/auth/login/{provider}", idpHandlers.HandleLogin)
|
||||
r.Get("/auth/callback/{provider}", idpHandlers.HandleCallback)
|
||||
slog.Info("external identity providers configured", "count", len(idpProviders))
|
||||
} else {
|
||||
// Return empty list when no providers configured
|
||||
r.Get("/auth/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"providers":[]}`))
|
||||
})
|
||||
}
|
||||
|
||||
// OAuth metadata (public, per RFC 8414)
|
||||
r.Get("/.well-known/oauth-authorization-server", authHandlers.HandleOAuthMetadata)
|
||||
|
||||
// A2A Agent Card discovery (public, no auth required)
|
||||
agentCardBaseURL := authCfg.IssuerURL // reuse the same base URL config
|
||||
r.Get("/.well-known/agent-card.json", a2a.NewAgentCardHandler(
|
||||
&a2aAgentListerAdapter{agentService: agentService},
|
||||
agentCardBaseURL,
|
||||
version,
|
||||
))
|
||||
|
||||
// OAuth endpoints
|
||||
r.Get("/oauth/authorize", authHandlers.HandleAuthorizeGet)
|
||||
r.Post("/oauth/authorize", authHandlers.HandleAuthorizePost)
|
||||
@@ -521,6 +576,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
r.Post("/auth/logout", authHandlers.HandleLogout)
|
||||
r.Get("/auth/me", authHandlers.HandleMe)
|
||||
r.Put("/auth/password", authHandlers.HandleChangePassword)
|
||||
r.Put("/api/auth/profile", authHandlers.HandleUpdateProfile)
|
||||
})
|
||||
|
||||
// MCP Streamable HTTP endpoint (requires agent auth: API key, managed key, or OAuth bearer)
|
||||
@@ -529,8 +585,28 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
r.Mount("/mcp", mcpSrv.Handler())
|
||||
})
|
||||
|
||||
// Create SSE hub for real-time events
|
||||
// A2A Gateway (requires auth: API key, managed key, or OAuth bearer)
|
||||
a2aTaskStore := a2a.NewA2ATaskStore(db.DB)
|
||||
a2aGateway := a2a.NewGateway(a2aTaskStore, msgService, agentService)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(agents.RequiredAuthMiddlewareWithOAuth(agentService, apiKeyService, oauthProvider))
|
||||
r.Post("/a2a", a2aGateway.HandleJSONRPC)
|
||||
})
|
||||
|
||||
// Create SSE hub and broadcaster for real-time events
|
||||
sseHub := api.NewSSEHub()
|
||||
sseBroadcaster := api.NewSSEBroadcaster(sseHub, agentService, channelService)
|
||||
|
||||
// Register broadcaster as a message listener so SSE events fire
|
||||
// for messages sent via MCP (agents) as well as the REST API.
|
||||
msgService.AddMessageListener(sseBroadcaster)
|
||||
|
||||
// Initialize push notification service
|
||||
pushStore := push.NewSQLiteStore(db.DB)
|
||||
pushService, err := push.NewService(pushStore, dataDir, logger)
|
||||
if err != nil {
|
||||
logger.Warn("push notification service unavailable", "error", err)
|
||||
}
|
||||
|
||||
// Mount API routes (traces, export, stats, metrics, attachments, messages, agents, channels, SSE)
|
||||
sessionMiddleware := api.SessionToOwnerMiddleware(userStore, sessionStore)
|
||||
@@ -544,7 +620,11 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
APIKeyService: apiKeyService,
|
||||
DeadLetterStore: deadLetterStore,
|
||||
SSEHub: sseHub,
|
||||
Broadcaster: sseBroadcaster,
|
||||
SessionMiddleware: sessionMiddleware,
|
||||
DB: db.DB,
|
||||
Version: version,
|
||||
PushService: pushService,
|
||||
})
|
||||
r.Mount("/", apiRouter)
|
||||
|
||||
@@ -633,6 +713,9 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
retentionWorker.Stop()
|
||||
}
|
||||
|
||||
// Stop stalemate worker
|
||||
stalemateWorker.Stop()
|
||||
|
||||
// Stop embedding pipeline
|
||||
if embPipeline != nil {
|
||||
embPipeline.Stop()
|
||||
@@ -731,6 +814,28 @@ func generateRandomPassword() string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// a2aAgentListerAdapter adapts agents.AgentService to a2a.AgentLister.
|
||||
type a2aAgentListerAdapter struct {
|
||||
agentService *agents.AgentService
|
||||
}
|
||||
|
||||
func (a *a2aAgentListerAdapter) ListAllActiveAgents(ctx context.Context) ([]a2a.AgentInfo, error) {
|
||||
agentsList, err := a.agentService.ListAllActiveAgents(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]a2a.AgentInfo, 0, len(agentsList))
|
||||
for _, agent := range agentsList {
|
||||
result = append(result, a2a.AgentInfo{
|
||||
Name: agent.Name,
|
||||
DisplayName: agent.DisplayName,
|
||||
Type: agent.Type,
|
||||
Capabilities: agent.Capabilities,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// agentListerAdapter adapts agents.AgentService to auth.AgentLister.
|
||||
type agentListerAdapter struct {
|
||||
agentService *agents.AgentService
|
||||
@@ -755,6 +860,28 @@ func (a *agentListerAdapter) ListAgentsByOwner(ctx context.Context, ownerID int6
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// idpAgentProvisioner adapts agents.AgentService + channels.Service to idp.AgentProvisioner.
|
||||
type idpAgentProvisioner struct {
|
||||
agentService *agents.AgentService
|
||||
channelService *channels.Service
|
||||
}
|
||||
|
||||
func (a *idpAgentProvisioner) ProvisionHumanAgent(ctx context.Context, username, displayName string, ownerID int64) error {
|
||||
humanAgent, err := a.agentService.EnsureHumanAgent(ctx, username, displayName, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure human agent: %w", err)
|
||||
}
|
||||
if humanAgent != nil {
|
||||
if chErr := a.channelService.EnsureMyAgentsChannel(ctx, username, humanAgent.Name); chErr != nil {
|
||||
slog.Warn("failed to ensure my-agents channel after IdP login",
|
||||
"username", username,
|
||||
"error", chErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDefaultMCPClient creates the "mcp-default" public OAuth client if it doesn't exist.
|
||||
// This client is used by MCP clients connecting via OAuth 2.1.
|
||||
func ensureDefaultMCPClient(ctx context.Context, db *sql.DB, bcryptCost int) {
|
||||
@@ -795,3 +922,16 @@ func ensureDefaultMCPClient(ctx context.Context, db *sql.DB, bcryptCost int) {
|
||||
"scopes", "mcp",
|
||||
)
|
||||
}
|
||||
|
||||
// channelLookupAdapter adapts channels.Service to messaging.ChannelLookup.
|
||||
type channelLookupAdapter struct {
|
||||
channelService *channels.Service
|
||||
}
|
||||
|
||||
func (a *channelLookupAdapter) GetChannelIDByName(ctx context.Context, name string) (int64, error) {
|
||||
ch, err := a.channelService.GetChannelByName(ctx, name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ch.ID, nil
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: run
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
@@ -68,6 +70,10 @@ spec:
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: run
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
# SynapBus Agent Communication Guide
|
||||
|
||||
How to configure Claude Code and Gemini CLI to proactively communicate via SynapBus.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
# Add SynapBus as user-scope MCP server (available in ALL projects)
|
||||
claude mcp add --transport http --scope user \
|
||||
--header "Authorization: Bearer $SYNAPBUS_API_KEY" \
|
||||
synapbus http://kubic.home.arpa:30088/mcp
|
||||
```
|
||||
|
||||
Or project-scope `.mcp.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"synapbus": {
|
||||
"type": "http",
|
||||
"url": "http://kubic.home.arpa:30088/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${SYNAPBUS_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
`~/.gemini/settings.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"synapbus": {
|
||||
"httpUrl": "http://kubic.home.arpa:30088/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${SYNAPBUS_API_KEY}"
|
||||
},
|
||||
"timeout": 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Gemini uses `httpUrl` (not `url`), and tool names are `mcp_synapbus_*` (single underscore) vs Claude's `mcp__synapbus__*` (double underscore).
|
||||
|
||||
---
|
||||
|
||||
## CLAUDE.md Instructions
|
||||
|
||||
Add this block to project `CLAUDE.md` or global `~/.claude/CLAUDE.md`:
|
||||
|
||||
```markdown
|
||||
## SynapBus Communication Protocol
|
||||
|
||||
You have access to SynapBus MCP tools for agent-to-agent messaging.
|
||||
|
||||
### On Session Start (MANDATORY)
|
||||
1. Call `my_status` FIRST before any other work.
|
||||
2. If there are pending DMs with priority >= 7, read and respond before starting planned work.
|
||||
3. Check #bugs-<your-project> for recent reports that may affect your task.
|
||||
4. Search #open-brain for context relevant to your current task.
|
||||
|
||||
### When to Post
|
||||
|
||||
| Event | Channel | Priority |
|
||||
|-------|---------|----------|
|
||||
| Bug found in own project | #bugs-<project> | 7-8 |
|
||||
| Bug found in another project | #bugs-<other-project> | 6-7 |
|
||||
| Bug fixed | Reply to original in #bugs-<project> | 5 |
|
||||
| Task completed (commit/PR) | Project channel or #my-agents-algis | 5 |
|
||||
| Research finding | #news-<topic> | 5 |
|
||||
| Need human approval | #approvals | 8-9 |
|
||||
| Long-term insight | #open-brain | 4 |
|
||||
| Session reflection | #reflections-<agent-name> | 3 |
|
||||
|
||||
### Message Formats
|
||||
|
||||
**Bug Report:**
|
||||
```
|
||||
**BUG: [One-line summary]**
|
||||
[Description]
|
||||
**Expected**: [what should happen]
|
||||
**Actual**: [what happens]
|
||||
**Severity**: High|Medium|Low
|
||||
```
|
||||
|
||||
**Bug Fix:**
|
||||
```
|
||||
**BUG — FIXED**: [summary]
|
||||
**Root cause**: [what was wrong]
|
||||
**Fix**: [what changed]
|
||||
```
|
||||
|
||||
**Task Completion:**
|
||||
```
|
||||
**COMPLETED: [task]**
|
||||
**Changes**: [files/components changed]
|
||||
**Tests**: [pass/fail]
|
||||
**Commit**: [hash]
|
||||
```
|
||||
|
||||
### Rules
|
||||
- Do NOT spam channels with progress updates ("reading file X", "running tests").
|
||||
- Do NOT block waiting for responses. Post and continue working.
|
||||
- Do NOT send API keys, passwords, or secrets in messages.
|
||||
- Do NOT create channels — suggest to human owner instead.
|
||||
- Do NOT post same info to multiple channels. Pick the most specific one.
|
||||
- Default priority is 5. Use 7+ only for genuine blockers or bugs.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GEMINI.md Instructions
|
||||
|
||||
Add to `~/.gemini/GEMINI.md` or project `.gemini/GEMINI.md`:
|
||||
|
||||
```markdown
|
||||
## SynapBus Communication
|
||||
|
||||
You have SynapBus MCP tools: my_status, send_message, search, execute.
|
||||
|
||||
### Workflow
|
||||
1. On session start, call `my_status` to check inbox.
|
||||
2. Before starting work, search SynapBus for relevant context.
|
||||
3. On task completion, post summary to appropriate channel.
|
||||
4. On bugs found, post structured report to #bugs-<project>.
|
||||
|
||||
### Channels
|
||||
- #open-brain — Shared knowledge base
|
||||
- #bugs-<project> — Bug reports per project
|
||||
- #news-<topic> — Research findings
|
||||
- #approvals — Items needing human approval
|
||||
- #reflections-<agent> — Development reflections
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
|
||||
### Claude Code: `/bus` command
|
||||
|
||||
Save as `~/.claude/commands/bus.md` (global) or `.claude/commands/bus.md` (per-project):
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Check SynapBus inbox, post updates, search context. Usage: /bus [check|post|search|bugs|complete]
|
||||
---
|
||||
|
||||
Parse $ARGUMENTS for subcommand (default: check).
|
||||
|
||||
### check (default)
|
||||
1. Call `my_status` via MCP
|
||||
2. Summarize: pending DMs, unread channels, mentions
|
||||
3. List action items (priority >= 7)
|
||||
|
||||
### search <query>
|
||||
1. Call execute: `call("search_messages", {"query": "<query>", "limit": 10})`
|
||||
2. Present results grouped by channel
|
||||
|
||||
### post <channel> <message>
|
||||
1. Send via `send_message` with channel param
|
||||
|
||||
### bugs [project]
|
||||
1. Read recent messages from #bugs-<project> (infer from repo if not specified)
|
||||
2. Summarize open bugs (no "FIXED" reply)
|
||||
|
||||
### complete
|
||||
1. Gather: git branch, recent commits, changed files
|
||||
2. Format task completion message
|
||||
3. Post to project channel
|
||||
```
|
||||
|
||||
### Claude Code: `/inbox` skill
|
||||
|
||||
Save as `~/.claude/commands/inbox.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Check SynapBus inbox for unread messages. Use at session start.
|
||||
---
|
||||
|
||||
1. Call `my_status` to get unread counts
|
||||
2. If pending DMs exist, read them via execute: `call("read_inbox", {})`
|
||||
3. Summarize what needs attention
|
||||
4. If action items exist, ask user how to proceed
|
||||
```
|
||||
|
||||
### Gemini CLI: Skills
|
||||
|
||||
Save as `~/.gemini/skills/synapbus-check/SKILL.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: synapbus-check
|
||||
description: Check SynapBus inbox and channel updates
|
||||
---
|
||||
Call my_status to check inbox. Summarize pending DMs and unread channels.
|
||||
If action items exist (priority >= 7), list them.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hooks
|
||||
|
||||
### Claude Code: Auto-check inbox on session start
|
||||
|
||||
`.claude/settings.json`:
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo '{\"hookSpecificOutput\":{\"additionalContext\":\"IMPORTANT: Call my_status on SynapBus MCP to check your inbox before starting work.\"}}'",
|
||||
"timeout": 2000
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gemini CLI: Session start reminder
|
||||
|
||||
`~/.gemini/settings.json` (add to existing):
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo '{\"hookSpecificOutput\":{\"additionalContext\":\"Call my_status first to check SynapBus messages.\"}}'",
|
||||
"timeout": 2000
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channel Structure
|
||||
|
||||
### Current
|
||||
| Channel | Purpose |
|
||||
|---------|---------|
|
||||
| #general | Cross-cutting discussion |
|
||||
| #open-brain | Long-term memory (509+ entries) |
|
||||
| #approvals | Human approval queue |
|
||||
| #new_posts | Blog post suggestions |
|
||||
| #bugs-synapbus | SynapBus bug reports |
|
||||
| #news-mcpproxy | MCPProxy research |
|
||||
| #news-synapbus | SynapBus research |
|
||||
| #news-personal-brand | Personal brand research |
|
||||
| #reflections-* | Per-agent development reflections |
|
||||
|
||||
### Recommended Additions
|
||||
| Channel | Purpose |
|
||||
|---------|---------|
|
||||
| #bugs-mcpproxy | MCPProxy bug reports |
|
||||
| #bugs-searcher | Searcher pipeline bugs |
|
||||
| #deployments | All deployment announcements |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Agent Communication Pattern
|
||||
|
||||
```
|
||||
Claude Code (dev agent) Gemini CLI (research agent)
|
||||
| |
|
||||
|-- MCP tools ──> SynapBus <── MCP tools --|
|
||||
| (kubic:30088) |
|
||||
| |
|
||||
├─ my_status (check inbox) ├─ my_status |
|
||||
├─ send_message (post/DM) ├─ send_message|
|
||||
├─ search (find context) ├─ search |
|
||||
└─ execute (advanced actions) └─ execute |
|
||||
```
|
||||
|
||||
Both agents connect with their own API keys. SynapBus identifies each by key.
|
||||
Messages, channels, and search are shared — any agent can read any public channel.
|
||||
|
||||
### Example Workflow
|
||||
1. **Gemini research agent** finds a security vulnerability, posts to `#news-mcpproxy`
|
||||
2. **Claude dev agent** starts session, calls `my_status`, sees unread in `#news-mcpproxy`
|
||||
3. Claude reads the finding, assesses impact, fixes the code
|
||||
4. Claude posts fix confirmation to `#news-mcpproxy` as a reply
|
||||
5. Both agents can search for this exchange later via semantic search
|
||||
|
||||
---
|
||||
|
||||
## Protocol Landscape (March 2026)
|
||||
|
||||
| Protocol | Purpose | Relation to SynapBus |
|
||||
|----------|---------|---------------------|
|
||||
| **MCP** | Agent ↔ Tool connectivity | SynapBus IS an MCP server |
|
||||
| **A2A** (Google) | Agent ↔ Agent task delegation | Complementary — A2A for cross-framework; SynapBus for persistent messaging |
|
||||
| **AG-UI** | Agent ↔ Frontend | SynapBus has its own Web UI |
|
||||
| **AGENTS.md** | Agent capability declaration | Could declare SynapBus agents |
|
||||
|
||||
SynapBus sits at the **messaging infrastructure layer**: persistent channels, semantic search, human-observable audit trail. No other MCP server combines all these properties in a single zero-dependency binary.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Why |
|
||||
|-------|-----|
|
||||
| Spam channels with progress updates | Floods channels, wastes embedding costs |
|
||||
| Block waiting for agent responses | Other agent may not run for hours |
|
||||
| Send secrets in messages | Messages are stored, searchable, visible in Web UI |
|
||||
| Post same info to multiple channels | Pick the most specific one |
|
||||
| Create channels autonomously | Suggest to human owner instead |
|
||||
| Act on messages > 7 days old without checking for follow-ups | May be already resolved |
|
||||
| Mark everything priority 8+ | Priority inflation kills triage |
|
||||
@@ -0,0 +1,234 @@
|
||||
# SynapBus Roadmap Research — March 2026
|
||||
|
||||
Synthesized findings from 7 parallel research agents covering protocol integration, deployment patterns, enterprise features, and agent coordination.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| Topic | Key Finding | Priority |
|
||||
|-------|-------------|----------|
|
||||
| **A2A Protocol** | Agent Cards (1-2 days), then inbound gateway (1-2 weeks). Pure Go SDK. | High |
|
||||
| **AG-UI Protocol** | Complement SSE, not replace. Medium-term value. | Low |
|
||||
| **User-level MCP** | Two agents: `claude-algis` + `gemini-algis`. Claude via MCPProxy, Gemini direct. | Do now |
|
||||
| **Mobile access** | Mobile-responsive Web UI via Cloudflare Tunnel. PWA push later. | Medium |
|
||||
| **Cross-device** | Cloudflare Tunnel works for MCP+SSE. Add Cloudflare Access for security. | Do now |
|
||||
| **Always-online agents** | Keep CronJobs + add K8s Job Handlers for reactive response. No daemons. | Medium |
|
||||
| **GitHub Actions** | Only for CI/CD tasks (PR review). K8s is better for research agents. | Low |
|
||||
| **Enterprise IdP** | `coreos/go-oidc/v3` + `golang.org/x/oauth2`. GitHub/Google/Azure AD. | Medium |
|
||||
| **Task acknowledgment** | Claim-process-done for DMs + ACK/DONE convention for channels + StalemateWorker. | High |
|
||||
|
||||
---
|
||||
|
||||
## 1. A2A Protocol Integration
|
||||
|
||||
**What**: Google's Agent-to-Agent protocol (v1.0, 22.6k stars, Linux Foundation).
|
||||
|
||||
**Why**: Makes SynapBus agents discoverable and callable by external frameworks (Google ADK, Microsoft Agent Framework, Strands, LangGraph).
|
||||
|
||||
**Phased approach**:
|
||||
- **Phase 1** (1-2 days): Expose `/.well-known/agent-card.json` from agent registry
|
||||
- **Phase 2** (1-2 weeks): Inbound A2A gateway — external agents send tasks → SynapBus routes as DMs
|
||||
- **Phase 3** (future): Outbound A2A client — SynapBus agents call external A2A agents
|
||||
|
||||
**Key mappings**: A2A Task → SynapBus Conversation, A2A Message → SynapBus Message, A2A Agent Card → SynapBus Agent record.
|
||||
|
||||
**Go SDK**: `github.com/a2aproject/a2a-go` — pure Go, compatible with zero-CGO constraint.
|
||||
|
||||
**vs MCP Tasks (SEP-1686)**: Complementary. MCP Tasks = long-running operations within existing MCP connection. A2A = cross-framework agent interop with discovery.
|
||||
|
||||
---
|
||||
|
||||
## 2. AG-UI Protocol
|
||||
|
||||
**What**: CopilotKit's Agent-User Interaction protocol (12.5k stars). Standardizes agent → frontend streaming.
|
||||
|
||||
**Assessment**: Medium-term value, not urgent. SynapBus's current SSE (notifications) and AG-UI (agent activity streaming) solve different problems.
|
||||
|
||||
**If pursued**: Expose `/ag-ui/run` endpoint that wraps channel activity as AG-UI events. Would let external React frontends (CopilotKit) connect to SynapBus agents.
|
||||
|
||||
**Recommendation**: Watch and plan, but don't build yet. Current SSE + Web UI covers all current use cases.
|
||||
|
||||
---
|
||||
|
||||
## 3. User-Level MCP + Agent Identity
|
||||
|
||||
**Recommendation: Two agent accounts** — `claude-algis` and `gemini-algis`.
|
||||
|
||||
| Tool | SynapBus Access | Agent Identity |
|
||||
|------|----------------|----------------|
|
||||
| Claude Code | Via MCPProxy (user-level, auto-auth) | `claude-algis` |
|
||||
| Gemini CLI | Direct connection (user-level) | `gemini-algis` |
|
||||
| Searcher agents | Direct per-agent keys (unchanged) | `research-*` |
|
||||
|
||||
**Why not one per project**: 20+ projects = 20+ dead agent accounts. **Why not one shared**: Can't tell Claude vs Gemini apart.
|
||||
|
||||
**MCPProxy gateway**: MCPProxy at `localhost:8080` already proxies to kubic. Add `Authorization: Bearer <claude-algis-key>` to the synapbus upstream config in `~/.mcpproxy/mcp_config.json`. All Claude Code projects get SynapBus via BM25 discovery.
|
||||
|
||||
**Gemini**: Direct connection in `~/.gemini/settings.json` with own key.
|
||||
|
||||
**Setup steps**:
|
||||
1. Create agents: `kubectl exec -n synapbus deploy/synapbus -- /synapbus agent create --name claude-algis --display-name "Claude (Algis)" --owner 1`
|
||||
2. Add Bearer header to MCPProxy synapbus upstream
|
||||
3. Remove project-level SynapBus configs from Claude Code
|
||||
4. Add direct SynapBus entry to Gemini settings
|
||||
|
||||
---
|
||||
|
||||
## 4. Mobile Access + Cross-Device
|
||||
|
||||
### Mobile (fastest path)
|
||||
Make Web UI mobile-responsive (sidebar → drawer, touch-friendly compose). Access via `hub.synapbus.dev` on phone. Existing SSE + auth work through Cloudflare Tunnel.
|
||||
|
||||
**Later**: PWA manifest + Web Push for background notifications. iOS supports Web Push since 16.4.
|
||||
|
||||
**Approval on mobile**: Add approve/reject buttons in Web UI for `#approvals` messages (detect `type: "approval_request"` in metadata).
|
||||
|
||||
### Cross-device (home + work)
|
||||
- Home kubic: agents connect locally (`localhost:30088`)
|
||||
- Work laptop: Claude/Gemini connect via `hub.synapbus.dev` tunnel
|
||||
- Benefits: shared context, research feeds dev work, bugs flow between environments
|
||||
|
||||
**Security**: Add Cloudflare Access policy on `hub.synapbus.dev` (email OTP or GitHub SSO). Service tokens for headless agents. OAuth 2.1 remains primary auth layer.
|
||||
|
||||
**Tunnel compatibility**: MCP Streamable HTTP + SSE both work through Cloudflare Tunnel. 30s heartbeats keep connections alive. ~20-50ms round-trip latency.
|
||||
|
||||
---
|
||||
|
||||
## 5. Always-Online Agents
|
||||
|
||||
### Recommended: Hybrid CronJob + K8s Job Handler
|
||||
|
||||
| Workload | Mechanism | Latency | Cost |
|
||||
|----------|-----------|---------|------|
|
||||
| Periodic research sweeps | K8s CronJob (existing) | 4-6h | Low |
|
||||
| Respond to messages/mentions | SynapBus K8s Job Handler | ~10s | Per-event |
|
||||
| Code review/CI tasks | GitHub Actions | ~1m | Free tier |
|
||||
| Always-on daemon | NOT RECOMMENDED | — | High |
|
||||
|
||||
**Keep CronJobs** for scheduled research (already working, staggered schedules).
|
||||
|
||||
**Add K8s Job Handlers** for real-time response: register handlers per agent for `message.received` and `message.mentioned` events. SynapBus spawns K8s Jobs with message context as env vars.
|
||||
|
||||
**Don't use long-running Deployments**: Context windows fill up, resources wasted on single-node MicroK8s.
|
||||
|
||||
**Don't use KEDA**: SynapBus's built-in K8s Job Runner already handles event-driven dispatch.
|
||||
|
||||
### Notable open-source projects
|
||||
- **Kelos**: K8s-native agent orchestration via CRDs (Tasks, AgentConfigs, TaskSpawners)
|
||||
- **Hortator**: Agent reincarnation pattern — checkpoint to `/memory/`, respawn with fresh context
|
||||
- **claude-code-action**: Official GitHub Action for Claude Code in CI/CD
|
||||
|
||||
---
|
||||
|
||||
## 6. Enterprise Identity Providers
|
||||
|
||||
### Architecture
|
||||
```
|
||||
External IdP (GitHub / Google / Azure AD)
|
||||
↓ OIDC Authorization Code Flow
|
||||
SynapBus Identity Layer (NEW: internal/auth/idp/)
|
||||
↓ Creates/links local User + session
|
||||
Existing Auth (Web UI sessions, OAuth AS for MCP, API keys)
|
||||
```
|
||||
|
||||
### Libraries
|
||||
- `coreos/go-oidc/v3` — OIDC discovery + ID token verification (Google, Azure AD)
|
||||
- `golang.org/x/oauth2` — OAuth flow (all providers, already indirect dep)
|
||||
- GitHub: manual OAuth + API calls (not OIDC-compliant)
|
||||
|
||||
### Database
|
||||
```sql
|
||||
CREATE TABLE user_identities (
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
provider TEXT NOT NULL, -- 'github', 'google', 'azuread'
|
||||
external_id TEXT NOT NULL, -- stable provider user ID
|
||||
email TEXT,
|
||||
UNIQUE(provider, external_id)
|
||||
);
|
||||
|
||||
CREATE TABLE identity_providers (
|
||||
id TEXT PRIMARY KEY, -- 'github', 'google', 'azuread-gcore'
|
||||
type TEXT NOT NULL, -- 'github', 'oidc'
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret_encrypted TEXT,
|
||||
issuer_url TEXT, -- OIDC discovery (NULL for GitHub)
|
||||
allowed_domains TEXT, -- '["gcore.com"]'
|
||||
group_mapping TEXT, -- '{"SynapBus-Admins":"admin"}'
|
||||
tenant_id TEXT, -- Azure AD
|
||||
enabled INTEGER DEFAULT 1
|
||||
);
|
||||
```
|
||||
|
||||
### Provider-specific notes
|
||||
- **GitHub**: `read:user` + `user:email` scopes. Map `github_user.id` → external_id.
|
||||
- **Google**: Full OIDC. Restrict to Workspace domain via `hd` claim. Validate server-side.
|
||||
- **Azure AD (Gcore)**: Tenant-specific OIDC. Group claims for role mapping. App Registration in Entra admin center. Handle >200 groups overage.
|
||||
|
||||
### Routes
|
||||
```
|
||||
GET /auth/providers → list enabled IdPs (for login page buttons)
|
||||
GET /auth/login/{provider} → redirect to IdP
|
||||
GET /auth/callback/{provider} → handle callback, create/link user, set session
|
||||
```
|
||||
|
||||
### Multi-tenant: One instance per org (matches local-first philosophy).
|
||||
|
||||
---
|
||||
|
||||
## 7. Task Acknowledgment & Enforcement
|
||||
|
||||
### DM Lifecycle (already built)
|
||||
`pending` → `processing` (claim) → `done` / `failed`
|
||||
|
||||
### CLAUDE.md Instructions (add to all projects)
|
||||
```markdown
|
||||
## Message Acknowledgment (MANDATORY)
|
||||
1. Call `claim_messages` to lock DMs to you
|
||||
2. Process each message
|
||||
3. `mark_done` (success) or `mark_done` with status "failed" + reason
|
||||
4. Never leave claimed messages orphaned — mark failed before session ends
|
||||
```
|
||||
|
||||
### Channel Convention (no code changes)
|
||||
- `ACK: <summary>` — I see it, working on it
|
||||
- `DONE: <summary>` — completed
|
||||
- `BLOCKED: <reason>` — cannot proceed
|
||||
- `DELEGATED: @<agent>` — passed to another agent
|
||||
|
||||
### Enforcement: StalemateWorker (new, small PR)
|
||||
Background worker (like ExpiryWorker/RetentionWorker):
|
||||
- `processing` messages > 24h → auto-fail with "claim timeout"
|
||||
- `pending` messages > 4h → send reminder DM (priority 7)
|
||||
- `pending` messages > 48h → escalate to `#approvals` (priority 9)
|
||||
|
||||
### Channel `reply_to` gap
|
||||
`send_channel_message` action lacks `reply_to` parameter. Add it to enable threaded acknowledgments in channels.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Do Now (zero code)
|
||||
1. Create `claude-algis` + `gemini-algis` agents
|
||||
2. Configure MCPProxy upstream with auth header
|
||||
3. Add acknowledgment protocol to CLAUDE.md / GEMINI.md
|
||||
4. Add SessionStart hooks for inbox checking
|
||||
|
||||
### Next Sprint
|
||||
5. StalemateWorker for message timeout/escalation
|
||||
6. Add `reply_to` to `send_channel_message` action
|
||||
7. A2A Agent Cards (`/.well-known/agent-card.json`)
|
||||
8. Mobile-responsive Web UI (sidebar drawer)
|
||||
|
||||
### Next Month
|
||||
9. A2A inbound gateway (external agents → SynapBus)
|
||||
10. K8s Job Handlers for reactive agent activation
|
||||
11. Enterprise IdP (GitHub + Google + Azure AD)
|
||||
12. PWA with Web Push notifications
|
||||
|
||||
### Future
|
||||
13. A2A outbound client (SynapBus agents → external agents)
|
||||
14. AG-UI endpoint for external frontends
|
||||
15. Telegram bot for mobile approvals
|
||||
16. Approval buttons in Web UI
|
||||
@@ -3,7 +3,11 @@ module github.com/synapbus/synapbus
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/TFMV/hnsw v0.4.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
github.com/evanw/esbuild v0.27.4
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mark3labs/mcp-go v0.45.0
|
||||
@@ -12,6 +16,7 @@ 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/oauth2 v0.36.0
|
||||
golang.org/x/time v0.9.0
|
||||
k8s.io/api v0.35.2
|
||||
k8s.io/apimachinery v0.35.2
|
||||
@@ -31,14 +36,13 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgraph-io/ristretto v1.0.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/evanw/esbuild v0.27.4 // 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-jose/go-jose/v4 v4.1.3 // 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
|
||||
@@ -47,6 +51,7 @@ require (
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/gobuffalo/pop/v6 v6.1.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
@@ -113,7 +118,6 @@ require (
|
||||
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
|
||||
|
||||
@@ -41,6 +41,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
|
||||
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/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
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=
|
||||
@@ -67,6 +69,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
@@ -117,6 +121,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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=
|
||||
@@ -162,6 +168,8 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
|
||||
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -205,6 +213,7 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0/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=
|
||||
@@ -569,7 +578,10 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -611,6 +623,9 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -653,6 +668,9 @@ golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfS
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -664,8 +682,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -680,6 +698,10 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -736,9 +758,13 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
@@ -748,7 +774,10 @@ golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuX
|
||||
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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
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=
|
||||
@@ -761,7 +790,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
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=
|
||||
@@ -827,6 +859,8 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -946,6 +980,7 @@ 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=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package a2a provides A2A Agent Card discovery for SynapBus.
|
||||
// The Agent Card is a JSON document that describes the hub and its registered
|
||||
// agents, following the A2A Agent Card specification.
|
||||
package a2a
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// AgentCard is the A2A Agent Card document returned by the discovery endpoint.
|
||||
type AgentCard struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
SupportedInterfaces []AgentInterface `json:"supported_interfaces"`
|
||||
Capabilities AgentCapabilities `json:"capabilities"`
|
||||
Skills []AgentSkill `json:"skills"`
|
||||
SecuritySchemes map[string]any `json:"security_schemes"`
|
||||
DefaultInputModes []string `json:"default_input_modes"`
|
||||
DefaultOutputModes []string `json:"default_output_modes"`
|
||||
}
|
||||
|
||||
// AgentInterface describes a protocol endpoint the hub supports.
|
||||
type AgentInterface struct {
|
||||
URL string `json:"url"`
|
||||
ProtocolBinding string `json:"protocol_binding"`
|
||||
}
|
||||
|
||||
// AgentCapabilities declares hub-level capabilities.
|
||||
type AgentCapabilities struct {
|
||||
Streaming bool `json:"streaming"`
|
||||
PushNotifications bool `json:"push_notifications"`
|
||||
}
|
||||
|
||||
// AgentSkill represents a single agent registered on the hub, mapped as an
|
||||
// A2A skill.
|
||||
type AgentSkill struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// AgentInfo is a lightweight struct used to pass agent data from the registry
|
||||
// into the card generator without leaking internal types.
|
||||
type AgentInfo struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Type string
|
||||
Capabilities json.RawMessage
|
||||
}
|
||||
|
||||
// GenerateAgentCard builds an AgentCard from the hub configuration and a list
|
||||
// of registered agents.
|
||||
func GenerateAgentCard(baseURL string, version string, agents []AgentInfo) *AgentCard {
|
||||
skills := make([]AgentSkill, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
skill := AgentSkill{
|
||||
ID: a.Name,
|
||||
Name: a.DisplayName,
|
||||
}
|
||||
if skill.Name == "" {
|
||||
skill.Name = a.Name
|
||||
}
|
||||
|
||||
// Parse capabilities JSON for description and tags.
|
||||
if len(a.Capabilities) > 0 {
|
||||
var caps map[string]interface{}
|
||||
if json.Unmarshal(a.Capabilities, &caps) == nil {
|
||||
if desc, ok := caps["description"].(string); ok {
|
||||
skill.Description = desc
|
||||
}
|
||||
if role, ok := caps["role"].(string); ok {
|
||||
skill.Tags = append(skill.Tags, role)
|
||||
}
|
||||
if tagsRaw, ok := caps["tags"]; ok {
|
||||
switch v := tagsRaw.(type) {
|
||||
case []interface{}:
|
||||
for _, t := range v {
|
||||
if s, ok := t.(string); ok {
|
||||
skill.Tags = append(skill.Tags, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add agent type as a tag.
|
||||
if a.Type != "" {
|
||||
skill.Tags = append(skill.Tags, a.Type)
|
||||
}
|
||||
|
||||
skills = append(skills, skill)
|
||||
}
|
||||
|
||||
return &AgentCard{
|
||||
Name: "SynapBus Hub",
|
||||
Description: "MCP-native agent-to-agent messaging hub",
|
||||
Version: version,
|
||||
SupportedInterfaces: []AgentInterface{
|
||||
{
|
||||
URL: baseURL + "/a2a",
|
||||
ProtocolBinding: "JSONRPC",
|
||||
},
|
||||
},
|
||||
Capabilities: AgentCapabilities{
|
||||
Streaming: true,
|
||||
PushNotifications: false,
|
||||
},
|
||||
Skills: skills,
|
||||
SecuritySchemes: map[string]any{
|
||||
"apiKey": map[string]any{
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "Authorization",
|
||||
"scheme": "Bearer",
|
||||
},
|
||||
"oauth2": map[string]any{
|
||||
"type": "oauth2",
|
||||
"flows": map[string]any{
|
||||
"authorizationCode": map[string]any{
|
||||
"authorizationUrl": baseURL + "/oauth/authorize",
|
||||
"tokenUrl": baseURL + "/oauth/token",
|
||||
"scopes": map[string]string{
|
||||
"mcp": "MCP protocol access",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultInputModes: []string{"text"},
|
||||
DefaultOutputModes: []string{"text"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateAgentCard_WithAgents(t *testing.T) {
|
||||
agents := []AgentInfo{
|
||||
{Name: "research-bot", DisplayName: "Research Bot", Type: "ai", Capabilities: json.RawMessage(`{"role":"researcher","description":"Searches the web"}`)},
|
||||
{Name: "social-commenter", DisplayName: "Social Commenter", Type: "ai", Capabilities: json.RawMessage(`{"tags":["social","marketing"]}`)},
|
||||
{Name: "data-analyst", DisplayName: "", Type: "ai", Capabilities: json.RawMessage(`{}`)},
|
||||
}
|
||||
|
||||
card := GenerateAgentCard("http://localhost:8080", "1.0.0", agents)
|
||||
|
||||
if card.Name != "SynapBus Hub" {
|
||||
t.Errorf("name = %q, want %q", card.Name, "SynapBus Hub")
|
||||
}
|
||||
if card.Version != "1.0.0" {
|
||||
t.Errorf("version = %q, want %q", card.Version, "1.0.0")
|
||||
}
|
||||
if len(card.Skills) != 3 {
|
||||
t.Fatalf("skills count = %d, want 3", len(card.Skills))
|
||||
}
|
||||
|
||||
// Verify first skill has description and tags from capabilities
|
||||
s0 := card.Skills[0]
|
||||
if s0.ID != "research-bot" {
|
||||
t.Errorf("skill[0].id = %q, want %q", s0.ID, "research-bot")
|
||||
}
|
||||
if s0.Name != "Research Bot" {
|
||||
t.Errorf("skill[0].name = %q, want %q", s0.Name, "Research Bot")
|
||||
}
|
||||
if s0.Description != "Searches the web" {
|
||||
t.Errorf("skill[0].description = %q, want %q", s0.Description, "Searches the web")
|
||||
}
|
||||
// Should have "researcher" from role + "ai" from type
|
||||
if len(s0.Tags) < 2 {
|
||||
t.Errorf("skill[0].tags = %v, expected at least 2 tags", s0.Tags)
|
||||
}
|
||||
|
||||
// Second skill should have tags from capabilities "tags" field
|
||||
s1 := card.Skills[1]
|
||||
if s1.ID != "social-commenter" {
|
||||
t.Errorf("skill[1].id = %q, want %q", s1.ID, "social-commenter")
|
||||
}
|
||||
foundSocial := false
|
||||
for _, tag := range s1.Tags {
|
||||
if tag == "social" {
|
||||
foundSocial = true
|
||||
}
|
||||
}
|
||||
if !foundSocial {
|
||||
t.Errorf("skill[1].tags = %v, expected 'social' tag", s1.Tags)
|
||||
}
|
||||
|
||||
// Third skill should use name as display name (since DisplayName is empty)
|
||||
s2 := card.Skills[2]
|
||||
if s2.Name != "data-analyst" {
|
||||
t.Errorf("skill[2].name = %q, want %q (fallback to Name)", s2.Name, "data-analyst")
|
||||
}
|
||||
|
||||
// Verify JSON serialization round-trips cleanly
|
||||
data, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal card: %v", err)
|
||||
}
|
||||
var decoded AgentCard
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal card: %v", err)
|
||||
}
|
||||
if decoded.Name != card.Name {
|
||||
t.Errorf("round-trip name mismatch")
|
||||
}
|
||||
if len(decoded.Skills) != 3 {
|
||||
t.Errorf("round-trip skills count = %d, want 3", len(decoded.Skills))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAgentCard_WithCapabilities_TagsPopulated(t *testing.T) {
|
||||
agents := []AgentInfo{
|
||||
{
|
||||
Name: "smart-agent",
|
||||
DisplayName: "Smart Agent",
|
||||
Type: "ai",
|
||||
Capabilities: json.RawMessage(`{"role":"analyst","description":"Analyzes data","tags":["ml","data"]}`),
|
||||
},
|
||||
}
|
||||
|
||||
card := GenerateAgentCard("http://example.com", "2.0.0", agents)
|
||||
|
||||
if len(card.Skills) != 1 {
|
||||
t.Fatalf("skills count = %d, want 1", len(card.Skills))
|
||||
}
|
||||
|
||||
skill := card.Skills[0]
|
||||
if skill.Description != "Analyzes data" {
|
||||
t.Errorf("description = %q, want %q", skill.Description, "Analyzes data")
|
||||
}
|
||||
|
||||
// Expect tags: "analyst" (from role), "ml", "data" (from tags), "ai" (from type)
|
||||
expectedTags := map[string]bool{"analyst": false, "ml": false, "data": false, "ai": false}
|
||||
for _, tag := range skill.Tags {
|
||||
if _, ok := expectedTags[tag]; ok {
|
||||
expectedTags[tag] = true
|
||||
}
|
||||
}
|
||||
for tag, found := range expectedTags {
|
||||
if !found {
|
||||
t.Errorf("missing expected tag %q in %v", tag, skill.Tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAgentCard_NoAgents(t *testing.T) {
|
||||
card := GenerateAgentCard("http://localhost:8080", "0.1.0", nil)
|
||||
|
||||
if card.Name != "SynapBus Hub" {
|
||||
t.Errorf("name = %q, want %q", card.Name, "SynapBus Hub")
|
||||
}
|
||||
if len(card.Skills) != 0 {
|
||||
t.Errorf("skills count = %d, want 0", len(card.Skills))
|
||||
}
|
||||
if len(card.SupportedInterfaces) != 1 {
|
||||
t.Fatalf("interfaces count = %d, want 1", len(card.SupportedInterfaces))
|
||||
}
|
||||
if card.SupportedInterfaces[0].URL != "http://localhost:8080/a2a" {
|
||||
t.Errorf("interface url = %q, want %q", card.SupportedInterfaces[0].URL, "http://localhost:8080/a2a")
|
||||
}
|
||||
if card.SecuritySchemes == nil {
|
||||
t.Error("security_schemes should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// mockAgentLister implements AgentLister for handler tests.
|
||||
type mockAgentLister struct {
|
||||
agents []AgentInfo
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockAgentLister) ListAllActiveAgents(_ context.Context) ([]AgentInfo, error) {
|
||||
return m.agents, m.err
|
||||
}
|
||||
|
||||
func TestHandler_Returns200WithCorrectContentType(t *testing.T) {
|
||||
lister := &mockAgentLister{
|
||||
agents: []AgentInfo{
|
||||
{Name: "bot-1", DisplayName: "Bot One", Type: "ai"},
|
||||
{Name: "bot-2", DisplayName: "Bot Two", Type: "ai"},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAgentCardHandler(lister, "http://localhost:8080", "1.0.0")
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/agent-card.json", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
ct := rr.Header().Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want %q", ct, "application/json")
|
||||
}
|
||||
|
||||
var card AgentCard
|
||||
if err := json.NewDecoder(rr.Body).Decode(&card); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if card.Name != "SynapBus Hub" {
|
||||
t.Errorf("card.name = %q, want %q", card.Name, "SynapBus Hub")
|
||||
}
|
||||
if len(card.Skills) != 2 {
|
||||
t.Errorf("card.skills count = %d, want 2", len(card.Skills))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_DerivesBaseURLFromRequest(t *testing.T) {
|
||||
lister := &mockAgentLister{agents: nil}
|
||||
|
||||
// Empty configuredBaseURL — should derive from request
|
||||
handler := NewAgentCardHandler(lister, "", "1.0.0")
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/agent-card.json", nil)
|
||||
req.Host = "myhost:9090"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var card AgentCard
|
||||
if err := json.NewDecoder(rr.Body).Decode(&card); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if card.SupportedInterfaces[0].URL != "http://myhost:9090/a2a" {
|
||||
t.Errorf("interface url = %q, want %q", card.SupportedInterfaces[0].URL, "http://myhost:9090/a2a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_RejectsNonGET(t *testing.T) {
|
||||
lister := &mockAgentLister{agents: nil}
|
||||
handler := NewAgentCardHandler(lister, "http://localhost:8080", "1.0.0")
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/agent-card.json", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
)
|
||||
|
||||
// MessagingService defines the messaging operations needed by the A2A gateway.
|
||||
type MessagingService interface {
|
||||
SendMessage(ctx context.Context, from, to, body string, opts messaging.SendOptions) (*messaging.Message, error)
|
||||
GetConversation(ctx context.Context, id int64) (*messaging.Conversation, []*messaging.Message, error)
|
||||
}
|
||||
|
||||
// AgentService defines the agent operations needed by the A2A gateway.
|
||||
type AgentService interface {
|
||||
GetAgent(ctx context.Context, name string) (*agents.Agent, error)
|
||||
}
|
||||
|
||||
// Gateway handles inbound A2A JSON-RPC requests.
|
||||
type Gateway struct {
|
||||
taskStore *A2ATaskStore
|
||||
msgService MessagingService
|
||||
agentService AgentService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewGateway creates a new A2A gateway.
|
||||
func NewGateway(taskStore *A2ATaskStore, msgService MessagingService, agentService AgentService) *Gateway {
|
||||
return &Gateway{
|
||||
taskStore: taskStore,
|
||||
msgService: msgService,
|
||||
agentService: agentService,
|
||||
logger: slog.Default().With("component", "a2a-gateway"),
|
||||
}
|
||||
}
|
||||
|
||||
// JSON-RPC 2.0 request/response types.
|
||||
|
||||
type jsonRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *jsonRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Standard JSON-RPC 2.0 error codes.
|
||||
const (
|
||||
errCodeParse = -32700
|
||||
errCodeInvalidReq = -32600
|
||||
errCodeNoMethod = -32601
|
||||
errCodeInvalidParams = -32602
|
||||
errCodeInternal = -32603
|
||||
)
|
||||
|
||||
// HandleJSONRPC dispatches incoming JSON-RPC 2.0 requests to the appropriate handler.
|
||||
func (g *Gateway) HandleJSONRPC(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB limit
|
||||
if err != nil {
|
||||
writeJSONRPC(w, nil, nil, &jsonRPCError{Code: errCodeParse, Message: "failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var req jsonRPCRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeJSONRPC(w, nil, nil, &jsonRPCError{Code: errCodeParse, Message: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.JSONRPC != "2.0" {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidReq, Message: "jsonrpc must be \"2.0\""})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the calling agent from auth context.
|
||||
callerAgent, ok := agents.AgentFromContext(r.Context())
|
||||
callerName := ""
|
||||
if ok && callerAgent != nil {
|
||||
callerName = callerAgent.Name
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "message.send":
|
||||
g.handleMessageSend(w, r.Context(), req, callerName)
|
||||
case "tasks.get":
|
||||
g.handleTasksGet(w, r.Context(), req)
|
||||
case "tasks.cancel":
|
||||
g.handleTasksCancel(w, r.Context(), req)
|
||||
default:
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeNoMethod, Message: fmt.Sprintf("unknown method: %s", req.Method)})
|
||||
}
|
||||
}
|
||||
|
||||
// message.send params
|
||||
|
||||
type messageSendParams struct {
|
||||
Message struct {
|
||||
Body string `json:"body"`
|
||||
Metadata struct {
|
||||
TargetAgent string `json:"target_agent"`
|
||||
} `json:"metadata"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
func (g *Gateway) handleMessageSend(w http.ResponseWriter, ctx context.Context, req jsonRPCRequest, callerName string) {
|
||||
var params messageSendParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "invalid params: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
targetAgent := params.Message.Metadata.TargetAgent
|
||||
if targetAgent == "" {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "params.message.metadata.target_agent is required"})
|
||||
return
|
||||
}
|
||||
|
||||
messageBody := params.Message.Body
|
||||
if messageBody == "" {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "params.message.body is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate target agent exists.
|
||||
_, err := g.agentService.GetAgent(ctx, targetAgent)
|
||||
if err != nil {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: fmt.Sprintf("target agent not found: %s", targetAgent)})
|
||||
return
|
||||
}
|
||||
|
||||
// Create task.
|
||||
taskID := uuid.New().String()
|
||||
contextID := uuid.New().String()
|
||||
|
||||
// Determine the sender name for the DM. Use the caller's identity if
|
||||
// authenticated, otherwise fall back to "a2a-gateway" so SendMessage
|
||||
// has a non-empty from field.
|
||||
senderName := callerName
|
||||
if senderName == "" {
|
||||
senderName = "a2a-gateway"
|
||||
}
|
||||
|
||||
// Build metadata containing the a2a_task_id.
|
||||
metaJSON, _ := json.Marshal(map[string]string{"a2a_task_id": taskID})
|
||||
|
||||
// Send DM to target agent.
|
||||
msg, err := g.msgService.SendMessage(ctx, senderName, targetAgent, messageBody, messaging.SendOptions{
|
||||
Subject: fmt.Sprintf("A2A Task %s", taskID),
|
||||
Metadata: string(metaJSON),
|
||||
})
|
||||
if err != nil {
|
||||
g.logger.Error("failed to send DM for A2A task", "task_id", taskID, "error", err)
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInternal, Message: "failed to deliver message: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
convID := msg.ConversationID
|
||||
task := &A2ATask{
|
||||
ID: taskID,
|
||||
ContextID: contextID,
|
||||
TargetAgent: targetAgent,
|
||||
SourceAgent: callerName,
|
||||
ConversationID: &convID,
|
||||
State: StateSubmitted,
|
||||
}
|
||||
|
||||
if err := g.taskStore.CreateTask(ctx, task); err != nil {
|
||||
g.logger.Error("failed to create A2A task", "task_id", taskID, "error", err)
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInternal, Message: "failed to create task"})
|
||||
return
|
||||
}
|
||||
|
||||
g.logger.Info("A2A task created",
|
||||
"task_id", taskID,
|
||||
"target_agent", targetAgent,
|
||||
"source_agent", callerName,
|
||||
"message_id", msg.ID,
|
||||
)
|
||||
|
||||
writeJSONRPC(w, req.ID, task, nil)
|
||||
}
|
||||
|
||||
// tasks.get params
|
||||
|
||||
type tasksGetParams struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (g *Gateway) handleTasksGet(w http.ResponseWriter, ctx context.Context, req jsonRPCRequest) {
|
||||
var params tasksGetParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "invalid params: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if params.ID == "" {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "params.id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
task, err := g.taskStore.GetTask(ctx, params.ID)
|
||||
if err != nil {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// If the task has a conversation and is still SUBMITTED, check whether the
|
||||
// target agent has replied, which indicates completion.
|
||||
if task.State == StateSubmitted && task.ConversationID != nil {
|
||||
_, msgs, err := g.msgService.GetConversation(ctx, *task.ConversationID)
|
||||
if err == nil && len(msgs) > 1 {
|
||||
// Check if the target agent sent a reply (any message from target after the first).
|
||||
for _, m := range msgs[1:] {
|
||||
if m.FromAgent == task.TargetAgent {
|
||||
task.State = StateCompleted
|
||||
_ = g.taskStore.UpdateTaskState(ctx, task.ID, StateCompleted)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSONRPC(w, req.ID, task, nil)
|
||||
}
|
||||
|
||||
// tasks.cancel params
|
||||
|
||||
type tasksCancelParams struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (g *Gateway) handleTasksCancel(w http.ResponseWriter, ctx context.Context, req jsonRPCRequest) {
|
||||
var params tasksCancelParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "invalid params: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if params.ID == "" {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "params.id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
task, err := g.taskStore.GetTask(ctx, params.ID)
|
||||
if err != nil {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Cannot cancel a terminal task.
|
||||
if task.State == StateCompleted || task.State == StateCanceled {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: fmt.Sprintf("task is already in terminal state: %s", task.State)})
|
||||
return
|
||||
}
|
||||
|
||||
if err := g.taskStore.UpdateTaskState(ctx, task.ID, StateCanceled); err != nil {
|
||||
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInternal, Message: "failed to cancel task"})
|
||||
return
|
||||
}
|
||||
|
||||
task.State = StateCanceled
|
||||
g.logger.Info("A2A task canceled", "task_id", task.ID)
|
||||
|
||||
writeJSONRPC(w, req.ID, task, nil)
|
||||
}
|
||||
|
||||
// writeJSONRPC writes a JSON-RPC 2.0 response.
|
||||
func writeJSONRPC(w http.ResponseWriter, id json.RawMessage, result any, rpcErr *jsonRPCError) {
|
||||
resp := jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Result: result,
|
||||
Error: rpcErr,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if rpcErr != nil {
|
||||
// Use 200 for JSON-RPC errors (per spec), but set result to nil.
|
||||
resp.Result = nil
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
// --- test helpers ---
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
// Seed a test user for owner_id FK
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testowner', 'hash', 'Test Owner')`)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func seedAgent(t *testing.T, db *sql.DB, name string) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', 1, 'testhash', 'active')`,
|
||||
name, name,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// mockMsgService implements MessagingService for testing.
|
||||
type mockMsgService struct {
|
||||
lastFrom string
|
||||
lastTo string
|
||||
lastBody string
|
||||
lastOpts messaging.SendOptions
|
||||
sendErr error
|
||||
returnMsg *messaging.Message
|
||||
convMsgs []*messaging.Message
|
||||
getConvErr error
|
||||
}
|
||||
|
||||
func (m *mockMsgService) SendMessage(_ context.Context, from, to, body string, opts messaging.SendOptions) (*messaging.Message, error) {
|
||||
m.lastFrom = from
|
||||
m.lastTo = to
|
||||
m.lastBody = body
|
||||
m.lastOpts = opts
|
||||
if m.sendErr != nil {
|
||||
return nil, m.sendErr
|
||||
}
|
||||
if m.returnMsg != nil {
|
||||
return m.returnMsg, nil
|
||||
}
|
||||
return &messaging.Message{
|
||||
ID: 1,
|
||||
ConversationID: 100,
|
||||
FromAgent: from,
|
||||
ToAgent: to,
|
||||
Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockMsgService) GetConversation(_ context.Context, id int64) (*messaging.Conversation, []*messaging.Message, error) {
|
||||
if m.getConvErr != nil {
|
||||
return nil, nil, m.getConvErr
|
||||
}
|
||||
conv := &messaging.Conversation{ID: id}
|
||||
return conv, m.convMsgs, nil
|
||||
}
|
||||
|
||||
// mockAgentService implements AgentService for testing.
|
||||
type mockAgentService struct {
|
||||
agents map[string]*agents.Agent
|
||||
}
|
||||
|
||||
func (m *mockAgentService) GetAgent(_ context.Context, name string) (*agents.Agent, error) {
|
||||
if a, ok := m.agents[name]; ok {
|
||||
return a, nil
|
||||
}
|
||||
return nil, fmt.Errorf("agent not found: %s", name)
|
||||
}
|
||||
|
||||
func newTestGateway(t *testing.T) (*Gateway, *mockMsgService, *mockAgentService, *A2ATaskStore) {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
seedAgent(t, db, "target-bot")
|
||||
seedAgent(t, db, "sender-bot")
|
||||
|
||||
taskStore := NewA2ATaskStore(db)
|
||||
msgSvc := &mockMsgService{}
|
||||
agentSvc := &mockAgentService{
|
||||
agents: map[string]*agents.Agent{
|
||||
"target-bot": {ID: 1, Name: "target-bot", Status: "active"},
|
||||
"sender-bot": {ID: 2, Name: "sender-bot", Status: "active"},
|
||||
},
|
||||
}
|
||||
|
||||
gw := NewGateway(taskStore, msgSvc, agentSvc)
|
||||
return gw, msgSvc, agentSvc, taskStore
|
||||
}
|
||||
|
||||
func jsonRPCCall(method string, params any) []byte {
|
||||
p, _ := json.Marshal(params)
|
||||
req := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": json.RawMessage(p),
|
||||
}
|
||||
b, _ := json.Marshal(req)
|
||||
return b
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, gw *Gateway, body []byte, agentCtx *agents.Agent) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/a2a", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if agentCtx != nil {
|
||||
req = req.WithContext(agents.ContextWithAgent(req.Context(), agentCtx))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
gw.HandleJSONRPC(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func parseResponse(t *testing.T, w *httptest.ResponseRecorder) jsonRPCResponse {
|
||||
t.Helper()
|
||||
var resp jsonRPCResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v\nbody: %s", err, w.Body.String())
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
func TestMessageSend_CreatesTaskAndDM(t *testing.T) {
|
||||
gw, msgSvc, _, taskStore := newTestGateway(t)
|
||||
|
||||
body := jsonRPCCall("message.send", map[string]any{
|
||||
"message": map[string]any{
|
||||
"body": "Hello target bot",
|
||||
"metadata": map[string]string{
|
||||
"target_agent": "target-bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
callerAgent := &agents.Agent{Name: "sender-bot"}
|
||||
w := doRequest(t, gw, body, callerAgent)
|
||||
resp := parseResponse(t, w)
|
||||
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
|
||||
// Verify a task was returned.
|
||||
resultBytes, _ := json.Marshal(resp.Result)
|
||||
var task A2ATask
|
||||
if err := json.Unmarshal(resultBytes, &task); err != nil {
|
||||
t.Fatalf("unmarshal task result: %v", err)
|
||||
}
|
||||
|
||||
if task.ID == "" {
|
||||
t.Error("task ID should not be empty")
|
||||
}
|
||||
if task.State != StateSubmitted {
|
||||
t.Errorf("task state = %q, want %q", task.State, StateSubmitted)
|
||||
}
|
||||
if task.TargetAgent != "target-bot" {
|
||||
t.Errorf("target_agent = %q, want %q", task.TargetAgent, "target-bot")
|
||||
}
|
||||
if task.SourceAgent != "sender-bot" {
|
||||
t.Errorf("source_agent = %q, want %q", task.SourceAgent, "sender-bot")
|
||||
}
|
||||
|
||||
// Verify the DM was sent.
|
||||
if msgSvc.lastTo != "target-bot" {
|
||||
t.Errorf("DM to = %q, want %q", msgSvc.lastTo, "target-bot")
|
||||
}
|
||||
if msgSvc.lastFrom != "sender-bot" {
|
||||
t.Errorf("DM from = %q, want %q", msgSvc.lastFrom, "sender-bot")
|
||||
}
|
||||
if msgSvc.lastBody != "Hello target bot" {
|
||||
t.Errorf("DM body = %q, want %q", msgSvc.lastBody, "Hello target bot")
|
||||
}
|
||||
|
||||
// Verify metadata contains a2a_task_id.
|
||||
var meta map[string]string
|
||||
if err := json.Unmarshal([]byte(msgSvc.lastOpts.Metadata), &meta); err != nil {
|
||||
t.Fatalf("unmarshal metadata: %v", err)
|
||||
}
|
||||
if meta["a2a_task_id"] != task.ID {
|
||||
t.Errorf("metadata a2a_task_id = %q, want %q", meta["a2a_task_id"], task.ID)
|
||||
}
|
||||
|
||||
// Verify task is persisted.
|
||||
stored, err := taskStore.GetTask(context.Background(), task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
if stored.State != StateSubmitted {
|
||||
t.Errorf("stored task state = %q, want %q", stored.State, StateSubmitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksGet_ReturnsTask(t *testing.T) {
|
||||
gw, _, _, taskStore := newTestGateway(t)
|
||||
|
||||
// Create a task directly.
|
||||
convID := int64(100)
|
||||
task := &A2ATask{
|
||||
ID: "test-task-123",
|
||||
ContextID: "ctx-123",
|
||||
TargetAgent: "target-bot",
|
||||
SourceAgent: "sender-bot",
|
||||
ConversationID: &convID,
|
||||
State: StateSubmitted,
|
||||
}
|
||||
if err := taskStore.CreateTask(context.Background(), task); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
|
||||
body := jsonRPCCall("tasks.get", map[string]string{"id": "test-task-123"})
|
||||
w := doRequest(t, gw, body, nil)
|
||||
resp := parseResponse(t, w)
|
||||
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
|
||||
resultBytes, _ := json.Marshal(resp.Result)
|
||||
var got A2ATask
|
||||
if err := json.Unmarshal(resultBytes, &got); err != nil {
|
||||
t.Fatalf("unmarshal task result: %v", err)
|
||||
}
|
||||
|
||||
if got.ID != "test-task-123" {
|
||||
t.Errorf("task ID = %q, want %q", got.ID, "test-task-123")
|
||||
}
|
||||
if got.State != StateSubmitted {
|
||||
t.Errorf("task state = %q, want %q", got.State, StateSubmitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksGet_CompletesOnReply(t *testing.T) {
|
||||
gw, msgSvc, _, taskStore := newTestGateway(t)
|
||||
|
||||
// Create a task.
|
||||
convID := int64(100)
|
||||
task := &A2ATask{
|
||||
ID: "task-reply-test",
|
||||
ContextID: "ctx-456",
|
||||
TargetAgent: "target-bot",
|
||||
SourceAgent: "sender-bot",
|
||||
ConversationID: &convID,
|
||||
State: StateSubmitted,
|
||||
}
|
||||
if err := taskStore.CreateTask(context.Background(), task); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
|
||||
// Simulate the target agent having replied.
|
||||
msgSvc.convMsgs = []*messaging.Message{
|
||||
{ID: 1, FromAgent: "sender-bot", Body: "Hello"},
|
||||
{ID: 2, FromAgent: "target-bot", Body: "Reply from target"},
|
||||
}
|
||||
|
||||
body := jsonRPCCall("tasks.get", map[string]string{"id": "task-reply-test"})
|
||||
w := doRequest(t, gw, body, nil)
|
||||
resp := parseResponse(t, w)
|
||||
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
|
||||
resultBytes, _ := json.Marshal(resp.Result)
|
||||
var got A2ATask
|
||||
if err := json.Unmarshal(resultBytes, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if got.State != StateCompleted {
|
||||
t.Errorf("task state = %q, want %q (target agent replied)", got.State, StateCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksCancel_TransitionsToCanceled(t *testing.T) {
|
||||
gw, _, _, taskStore := newTestGateway(t)
|
||||
|
||||
task := &A2ATask{
|
||||
ID: "task-cancel-test",
|
||||
ContextID: "ctx-789",
|
||||
TargetAgent: "target-bot",
|
||||
SourceAgent: "sender-bot",
|
||||
State: StateSubmitted,
|
||||
}
|
||||
if err := taskStore.CreateTask(context.Background(), task); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
|
||||
body := jsonRPCCall("tasks.cancel", map[string]string{"id": "task-cancel-test"})
|
||||
w := doRequest(t, gw, body, nil)
|
||||
resp := parseResponse(t, w)
|
||||
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
|
||||
resultBytes, _ := json.Marshal(resp.Result)
|
||||
var got A2ATask
|
||||
if err := json.Unmarshal(resultBytes, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if got.State != StateCanceled {
|
||||
t.Errorf("task state = %q, want %q", got.State, StateCanceled)
|
||||
}
|
||||
|
||||
// Verify persisted state.
|
||||
stored, err := taskStore.GetTask(context.Background(), "task-cancel-test")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
if stored.State != StateCanceled {
|
||||
t.Errorf("stored state = %q, want %q", stored.State, StateCanceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksCancel_TerminalStateError(t *testing.T) {
|
||||
gw, _, _, taskStore := newTestGateway(t)
|
||||
|
||||
task := &A2ATask{
|
||||
ID: "task-already-done",
|
||||
ContextID: "ctx-done",
|
||||
TargetAgent: "target-bot",
|
||||
State: StateCompleted,
|
||||
}
|
||||
if err := taskStore.CreateTask(context.Background(), task); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
|
||||
body := jsonRPCCall("tasks.cancel", map[string]string{"id": "task-already-done"})
|
||||
w := doRequest(t, gw, body, nil)
|
||||
resp := parseResponse(t, w)
|
||||
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected error for canceling terminal task")
|
||||
}
|
||||
if resp.Error.Code != errCodeInvalidParams {
|
||||
t.Errorf("error code = %d, want %d", resp.Error.Code, errCodeInvalidParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageSend_NonExistentAgent(t *testing.T) {
|
||||
gw, _, _, _ := newTestGateway(t)
|
||||
|
||||
body := jsonRPCCall("message.send", map[string]any{
|
||||
"message": map[string]any{
|
||||
"body": "Hello ghost",
|
||||
"metadata": map[string]string{
|
||||
"target_agent": "does-not-exist",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := doRequest(t, gw, body, nil)
|
||||
resp := parseResponse(t, w)
|
||||
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected error for non-existent target agent")
|
||||
}
|
||||
if resp.Error.Code != errCodeInvalidParams {
|
||||
t.Errorf("error code = %d, want %d", resp.Error.Code, errCodeInvalidParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJSONRPC(t *testing.T) {
|
||||
gw, _, _, _ := newTestGateway(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "not JSON",
|
||||
body: "this is not json",
|
||||
},
|
||||
{
|
||||
name: "wrong jsonrpc version",
|
||||
body: `{"jsonrpc":"1.0","id":1,"method":"message.send","params":{}}`,
|
||||
},
|
||||
{
|
||||
name: "unknown method",
|
||||
body: `{"jsonrpc":"2.0","id":1,"method":"unknown.method","params":{}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/a2a", bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
gw.HandleJSONRPC(w, req)
|
||||
|
||||
var resp jsonRPCResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v\nbody: %s", err, w.Body.String())
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Error("expected error response")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedRequest_NoAgentContext(t *testing.T) {
|
||||
// This tests that message.send works even without an authenticated agent
|
||||
// in context (caller is anonymous), using "a2a-gateway" as the sender.
|
||||
gw, msgSvc, _, _ := newTestGateway(t)
|
||||
|
||||
body := jsonRPCCall("message.send", map[string]any{
|
||||
"message": map[string]any{
|
||||
"body": "Hello from anonymous",
|
||||
"metadata": map[string]string{
|
||||
"target_agent": "target-bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// No agent context — simulates unauthenticated-at-gateway-level
|
||||
// (in practice, the auth middleware would block this; this tests the
|
||||
// gateway's fallback behavior).
|
||||
w := doRequest(t, gw, body, nil)
|
||||
resp := parseResponse(t, w)
|
||||
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
|
||||
// Should use "a2a-gateway" as sender when no caller agent.
|
||||
if msgSvc.lastFrom != "a2a-gateway" {
|
||||
t.Errorf("DM from = %q, want %q", msgSvc.lastFrom, "a2a-gateway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMethodNotAllowed(t *testing.T) {
|
||||
gw, _, _, _ := newTestGateway(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/a2a", nil)
|
||||
w := httptest.NewRecorder()
|
||||
gw.HandleJSONRPC(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AgentLister abstracts the operation of listing active non-human agents.
|
||||
type AgentLister interface {
|
||||
ListAllActiveAgents(ctx context.Context) ([]AgentInfo, error)
|
||||
}
|
||||
|
||||
// NewAgentCardHandler returns an http.HandlerFunc that serves the A2A Agent
|
||||
// Card JSON document at /.well-known/agent-card.json.
|
||||
//
|
||||
// The handler is public (no auth required) because Agent Cards are meant for
|
||||
// discovery. If configuredBaseURL is empty the base URL is derived from the
|
||||
// incoming request (respecting X-Forwarded-* headers).
|
||||
func NewAgentCardHandler(agentLister AgentLister, configuredBaseURL string, version string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Derive base URL from request if not configured.
|
||||
baseURL := configuredBaseURL
|
||||
if baseURL == "" {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
host := r.Host
|
||||
if fwdHost := r.Header.Get("X-Forwarded-Host"); fwdHost != "" {
|
||||
host = fwdHost
|
||||
}
|
||||
baseURL = scheme + "://" + host
|
||||
}
|
||||
|
||||
agents, err := agentLister.ListAllActiveAgents(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
card := GenerateAgentCard(baseURL, version, agents)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
json.NewEncoder(w).Encode(card)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package a2a provides the A2A (Agent-to-Agent) inbound gateway for SynapBus.
|
||||
// External A2A-compliant agents can send tasks to SynapBus agents via JSON-RPC.
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Task states following the A2A protocol.
|
||||
const (
|
||||
StateSubmitted = "SUBMITTED"
|
||||
StateCompleted = "COMPLETED"
|
||||
StateCanceled = "CANCELED"
|
||||
)
|
||||
|
||||
// A2ATask represents an inbound A2A task tracked by the gateway.
|
||||
type A2ATask struct {
|
||||
ID string `json:"id"`
|
||||
ContextID string `json:"context_id"`
|
||||
TargetAgent string `json:"target_agent"`
|
||||
SourceAgent string `json:"source_agent"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
State string `json:"state"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// A2ATaskStore provides CRUD operations for A2A tasks backed by SQLite.
|
||||
type A2ATaskStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewA2ATaskStore creates a new task store.
|
||||
func NewA2ATaskStore(db *sql.DB) *A2ATaskStore {
|
||||
return &A2ATaskStore{db: db}
|
||||
}
|
||||
|
||||
// CreateTask inserts a new A2A task into the database.
|
||||
func (s *A2ATaskStore) CreateTask(ctx context.Context, task *A2ATask) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO a2a_tasks (id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
task.ID, task.ContextID, task.TargetAgent, task.SourceAgent, task.ConversationID, task.State,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert a2a task: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTask returns an A2A task by its ID.
|
||||
func (s *A2ATaskStore) GetTask(ctx context.Context, id string) (*A2ATask, error) {
|
||||
var task A2ATask
|
||||
var conversationID sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
|
||||
FROM a2a_tasks WHERE id = ?`, id,
|
||||
).Scan(&task.ID, &task.ContextID, &task.TargetAgent, &task.SourceAgent,
|
||||
&conversationID, &task.State, &task.CreatedAt, &task.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("a2a task not found: %s", id)
|
||||
}
|
||||
return nil, fmt.Errorf("get a2a task: %w", err)
|
||||
}
|
||||
if conversationID.Valid {
|
||||
task.ConversationID = &conversationID.Int64
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// UpdateTaskState transitions a task to a new state.
|
||||
func (s *A2ATaskStore) UpdateTaskState(ctx context.Context, id, state string) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE a2a_tasks SET state = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
state, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update a2a task state: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("a2a task not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTasks returns tasks for a target agent, optionally filtered by state.
|
||||
func (s *A2ATaskStore) ListTasks(ctx context.Context, targetAgent, state string) ([]*A2ATask, error) {
|
||||
var query string
|
||||
var args []any
|
||||
|
||||
if state != "" {
|
||||
query = `SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
|
||||
FROM a2a_tasks WHERE target_agent = ? AND state = ? ORDER BY created_at DESC`
|
||||
args = []any{targetAgent, state}
|
||||
} else {
|
||||
query = `SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
|
||||
FROM a2a_tasks WHERE target_agent = ? ORDER BY created_at DESC`
|
||||
args = []any{targetAgent}
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list a2a tasks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tasks []*A2ATask
|
||||
for rows.Next() {
|
||||
var task A2ATask
|
||||
var conversationID sql.NullInt64
|
||||
if err := rows.Scan(&task.ID, &task.ContextID, &task.TargetAgent, &task.SourceAgent,
|
||||
&conversationID, &task.State, &task.CreatedAt, &task.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan a2a task: %w", err)
|
||||
}
|
||||
if conversationID.Valid {
|
||||
task.ConversationID = &conversationID.Int64
|
||||
}
|
||||
tasks = append(tasks, &task)
|
||||
}
|
||||
if tasks == nil {
|
||||
tasks = []*A2ATask{}
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
@@ -306,6 +306,7 @@ func allActions() []Action {
|
||||
{Name: "body", Type: "string", Description: "Message body text", Required: true},
|
||||
{Name: "priority", Type: "number", Description: "Message priority (1-10, default 5)", Default: "5"},
|
||||
{Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"},
|
||||
{Name: "reply_to", Type: "number", Description: "Message ID to reply to (creates a thread)", Required: false},
|
||||
},
|
||||
Returns: "JSON with channel_id, message_id, and status 'sent'",
|
||||
Examples: []Example{
|
||||
|
||||
@@ -139,6 +139,8 @@ func (s *AdminServer) dispatch(req Request) Response {
|
||||
return s.handleAgentDelete(ctx, req.Args)
|
||||
case "agent.revoke_key":
|
||||
return s.handleAgentRevokeKey(ctx, req.Args)
|
||||
case "agent.update_capabilities":
|
||||
return s.handleAgentUpdateCapabilities(ctx, req.Args)
|
||||
|
||||
// --- audit commands ---
|
||||
case "audit.list":
|
||||
@@ -446,6 +448,35 @@ func (s *AdminServer) handleAgentRevokeKey(ctx context.Context, args json.RawMes
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *AdminServer) handleAgentUpdateCapabilities(ctx context.Context, args json.RawMessage) Response {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Capabilities json.RawMessage `json:"capabilities"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return Response{OK: false, Error: "invalid args: " + err.Error()}
|
||||
}
|
||||
if p.Name == "" {
|
||||
return Response{OK: false, Error: "name is required"}
|
||||
}
|
||||
if len(p.Capabilities) == 0 {
|
||||
return Response{OK: false, Error: "capabilities is required"}
|
||||
}
|
||||
if !json.Valid(p.Capabilities) {
|
||||
return Response{OK: false, Error: "capabilities must be valid JSON"}
|
||||
}
|
||||
|
||||
agent, err := s.services.Agents.UpdateAgent(ctx, p.Name, "", p.Capabilities)
|
||||
if err != nil {
|
||||
return Response{OK: false, Error: err.Error()}
|
||||
}
|
||||
|
||||
return Response{OK: true, Data: map[string]interface{}{
|
||||
"name": agent.Name,
|
||||
"capabilities": json.RawMessage(agent.Capabilities),
|
||||
}}
|
||||
}
|
||||
|
||||
// ---------- audit handlers ----------
|
||||
|
||||
func (s *AdminServer) handleAuditList(ctx context.Context, args json.RawMessage) Response {
|
||||
|
||||
@@ -229,9 +229,13 @@ func RequiredAuthMiddlewareWithOAuth(service *AgentService, keyService *apikeys.
|
||||
|
||||
// resolveOAuthToken introspects an OAuth bearer token and extracts agent identity.
|
||||
func resolveOAuthToken(ctx context.Context, provider fosite.OAuth2Provider, token string, service *AgentService) (agentName string, ownerID string, ok bool) {
|
||||
// Use a fositeSession-compatible struct for introspection.
|
||||
// We import the type indirectly through the fosite interface.
|
||||
_, ar, err := provider.IntrospectToken(ctx, token, fosite.AccessToken, &oauthIntrospectSession{})
|
||||
// Decouple from the HTTP request context so token introspection completes
|
||||
// even if the client disconnects (fixes "context canceled" errors during
|
||||
// concurrent MCP connections from claude.ai).
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, ar, err := provider.IntrospectToken(dbCtx, token, fosite.AccessToken, &oauthIntrospectSession{})
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
@@ -245,6 +245,12 @@ func (s *AgentService) ListAgents(ctx context.Context, ownerID int64) ([]*Agent,
|
||||
return s.store.ListAgentsByOwner(ctx, ownerID)
|
||||
}
|
||||
|
||||
// ListAllActiveAgents returns all active non-human agents across all owners.
|
||||
// Used for the A2A Agent Card discovery endpoint.
|
||||
func (s *AgentService) ListAllActiveAgents(ctx context.Context) ([]*Agent, error) {
|
||||
return s.store.ListAllActiveAgents(ctx)
|
||||
}
|
||||
|
||||
// RevokeKey generates a new API key for an agent. Only the owner can do this.
|
||||
// Returns the agent and the new raw API key (shown once).
|
||||
func (s *AgentService) RevokeKey(ctx context.Context, name string, ownerID int64) (*Agent, string, error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ type AgentStore interface {
|
||||
UpdateAgent(ctx context.Context, agent *Agent) error
|
||||
DeactivateAgent(ctx context.Context, name string) error
|
||||
ListActiveAgents(ctx context.Context) ([]*Agent, error)
|
||||
ListAllActiveAgents(ctx context.Context) ([]*Agent, error)
|
||||
ListAgentsByOwner(ctx context.Context, ownerID int64) ([]*Agent, error)
|
||||
SearchAgentsByCapability(ctx context.Context, query string) ([]*Agent, error)
|
||||
GetHumanAgentByOwner(ctx context.Context, ownerID int64) (*Agent, error)
|
||||
@@ -112,6 +113,18 @@ func (s *SQLiteAgentStore) ListActiveAgents(ctx context.Context) ([]*Agent, erro
|
||||
return s.scanAgents(rows)
|
||||
}
|
||||
|
||||
func (s *SQLiteAgentStore) ListAllActiveAgents(ctx context.Context) ([]*Agent, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
|
||||
FROM agents WHERE status = 'active' AND type != 'human' ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return s.scanAgents(rows)
|
||||
}
|
||||
|
||||
func (s *SQLiteAgentStore) ListAgentsByOwner(ctx context.Context, ownerID int64) ([]*Agent, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
)
|
||||
|
||||
// AnalyticsHandler serves analytics endpoints for the Web UI dashboard.
|
||||
type AnalyticsHandler struct {
|
||||
db *sql.DB
|
||||
agentService *agents.AgentService
|
||||
channelService *channels.Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewAnalyticsHandler creates a new analytics handler.
|
||||
func NewAnalyticsHandler(db *sql.DB, agentService *agents.AgentService, channelService *channels.Service) *AnalyticsHandler {
|
||||
return &AnalyticsHandler{
|
||||
db: db,
|
||||
agentService: agentService,
|
||||
channelService: channelService,
|
||||
logger: slog.Default().With("component", "api.analytics"),
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpan parses a span parameter and returns the cutoff time and strftime format string.
|
||||
func parseSpan(span string) (time.Time, string, error) {
|
||||
now := time.Now().UTC()
|
||||
switch span {
|
||||
case "1h":
|
||||
return now.Add(-1 * time.Hour), "%Y-%m-%d %H:%M", nil
|
||||
case "4h":
|
||||
return now.Add(-4 * time.Hour), "%Y-%m-%d %H:%M", nil
|
||||
case "24h":
|
||||
return now.Add(-24 * time.Hour), "%Y-%m-%d %H:00", nil
|
||||
case "7d":
|
||||
return now.Add(-7 * 24 * time.Hour), "%Y-%m-%d", nil
|
||||
case "30d":
|
||||
return now.Add(-30 * 24 * time.Hour), "%Y-%m-%d", nil
|
||||
default:
|
||||
return time.Time{}, "", fmt.Errorf("invalid span: %s (valid: 1h, 4h, 24h, 7d, 30d)", span)
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline handles GET /api/analytics/timeline?span=24h.
|
||||
func (h *AnalyticsHandler) Timeline(w http.ResponseWriter, r *http.Request) {
|
||||
span := r.URL.Query().Get("span")
|
||||
if span == "" {
|
||||
span = "24h"
|
||||
}
|
||||
|
||||
cutoff, strftimeFmt, err := parseSpan(span)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// For 1h and 4h spans, bucket by 5-minute intervals using SQL expression
|
||||
var query string
|
||||
if span == "1h" || span == "4h" {
|
||||
// Round minutes down to nearest 5 using printf
|
||||
query = `SELECT strftime('%Y-%m-%d %H:', created_at) || printf('%02d', (CAST(strftime('%M', created_at) AS INTEGER) / 5) * 5) AS bucket, COUNT(*) AS count FROM messages WHERE created_at >= ? GROUP BY bucket ORDER BY bucket`
|
||||
} else {
|
||||
query = `SELECT strftime(?, created_at) AS bucket, COUNT(*) AS count FROM messages WHERE created_at >= ? GROUP BY bucket ORDER BY bucket`
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
Time string `json:"time"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
var buckets []bucket
|
||||
var total int
|
||||
|
||||
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
|
||||
|
||||
var rows *sql.Rows
|
||||
if span == "1h" || span == "4h" {
|
||||
rows, err = h.db.QueryContext(r.Context(), query, cutoffStr)
|
||||
} else {
|
||||
rows, err = h.db.QueryContext(r.Context(), query, strftimeFmt, cutoffStr)
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Error("timeline query failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query timeline"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var b bucket
|
||||
if err := rows.Scan(&b.Time, &b.Count); err != nil {
|
||||
h.logger.Error("timeline scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
buckets = append(buckets, b)
|
||||
total += b.Count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
h.logger.Error("timeline rows error", "error", err)
|
||||
}
|
||||
|
||||
if buckets == nil {
|
||||
buckets = []bucket{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"span": span,
|
||||
"buckets": buckets,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// TopAgents handles GET /api/analytics/top-agents?span=24h&limit=5.
|
||||
func (h *AnalyticsHandler) TopAgents(w http.ResponseWriter, r *http.Request) {
|
||||
span := r.URL.Query().Get("span")
|
||||
if span == "" {
|
||||
span = "24h"
|
||||
}
|
||||
|
||||
cutoff, _, err := parseSpan(span)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
limit := 5
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(),
|
||||
`SELECT from_agent, COUNT(*) as count FROM messages WHERE created_at >= ? AND from_agent != '' GROUP BY from_agent ORDER BY count DESC LIMIT ?`,
|
||||
cutoffStr, limit,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("top-agents query failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query top agents"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type agentStat struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
var agentStats []agentStat
|
||||
for rows.Next() {
|
||||
var s agentStat
|
||||
if err := rows.Scan(&s.Name, &s.Count); err != nil {
|
||||
h.logger.Error("top-agents scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Look up display name from agent service
|
||||
if h.agentService != nil {
|
||||
if agent, err := h.agentService.GetAgent(r.Context(), s.Name); err == nil {
|
||||
s.DisplayName = agent.DisplayName
|
||||
}
|
||||
}
|
||||
if s.DisplayName == "" {
|
||||
s.DisplayName = s.Name
|
||||
}
|
||||
|
||||
agentStats = append(agentStats, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
h.logger.Error("top-agents rows error", "error", err)
|
||||
}
|
||||
|
||||
if agentStats == nil {
|
||||
agentStats = []agentStat{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"span": span,
|
||||
"agents": agentStats,
|
||||
})
|
||||
}
|
||||
|
||||
// TopChannels handles GET /api/analytics/top-channels?span=24h&limit=5.
|
||||
func (h *AnalyticsHandler) TopChannels(w http.ResponseWriter, r *http.Request) {
|
||||
span := r.URL.Query().Get("span")
|
||||
if span == "" {
|
||||
span = "24h"
|
||||
}
|
||||
|
||||
cutoff, _, err := parseSpan(span)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
limit := 5
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(),
|
||||
`SELECT c.name, COUNT(*) as count FROM messages m JOIN channels c ON m.channel_id = c.id WHERE m.created_at >= ? AND m.channel_id IS NOT NULL GROUP BY c.name ORDER BY count DESC LIMIT ?`,
|
||||
cutoffStr, limit,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("top-channels query failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query top channels"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type channelStat struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
var channelStats []channelStat
|
||||
for rows.Next() {
|
||||
var s channelStat
|
||||
if err := rows.Scan(&s.Name, &s.Count); err != nil {
|
||||
h.logger.Error("top-channels scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
channelStats = append(channelStats, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
h.logger.Error("top-channels rows error", "error", err)
|
||||
}
|
||||
|
||||
if channelStats == nil {
|
||||
channelStats = []channelStat{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"span": span,
|
||||
"channels": channelStats,
|
||||
})
|
||||
}
|
||||
|
||||
// Summary handles GET /api/analytics/summary.
|
||||
func (h *AnalyticsHandler) Summary(w http.ResponseWriter, r *http.Request) {
|
||||
var totalAgents, totalChannels, totalMessages int
|
||||
|
||||
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM agents WHERE status = 'active'`).Scan(&totalAgents); err != nil {
|
||||
h.logger.Error("summary agents count failed", "error", err)
|
||||
}
|
||||
|
||||
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM channels`).Scan(&totalChannels); err != nil {
|
||||
h.logger.Error("summary channels count failed", "error", err)
|
||||
}
|
||||
|
||||
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM messages`).Scan(&totalMessages); err != nil {
|
||||
h.logger.Error("summary messages count failed", "error", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"total_agents": totalAgents,
|
||||
"total_channels": totalChannels,
|
||||
"total_messages": totalMessages,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
func newAnalyticsTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:analytics_%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
// Seed test users
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testuser', 'hash', 'Test User')`)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func seedAnalyticsAgent(t *testing.T, db *sql.DB, name, displayName string, ownerID int64) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', ?, 'testhash', 'active')`,
|
||||
name, displayName, ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedAnalyticsChannel(t *testing.T, db *sql.DB, name, createdBy string) int64 {
|
||||
t.Helper()
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO channels (name, description, type, is_private, created_by, created_at, updated_at) VALUES (?, '', 'standard', 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
name, createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed channel %s: %v", name, err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func seedAnalyticsMessage(t *testing.T, db *sql.DB, from, to, body string, channelID *int64, createdAt time.Time) {
|
||||
t.Helper()
|
||||
|
||||
// Ensure a conversation exists
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('test', ?, ?, ?)`,
|
||||
from, createdAt.Format("2006-01-02 15:04:05"), createdAt.Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed conversation: %v", err)
|
||||
}
|
||||
convID, _ := result.LastInsertId()
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, body, priority, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 5, 'pending', '{}', ?, ?)`,
|
||||
convID, from, to, channelID, body, createdAt.Format("2006-01-02 15:04:05"), createdAt.Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAnalyticsRouter(t *testing.T, db *sql.DB) chi.Router {
|
||||
t.Helper()
|
||||
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
agentService := agents.NewAgentService(agentStore, nil)
|
||||
|
||||
channelStore := channels.NewSQLiteChannelStore(db)
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgService := messaging.NewMessagingService(msgStore, nil)
|
||||
channelService := channels.NewService(channelStore, msgService, nil)
|
||||
|
||||
analyticsHandler := NewAnalyticsHandler(db, agentService, channelService)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(OwnerAuthMiddleware)
|
||||
r.Get("/api/analytics/timeline", analyticsHandler.Timeline)
|
||||
r.Get("/api/analytics/top-agents", analyticsHandler.TopAgents)
|
||||
r.Get("/api/analytics/top-channels", analyticsHandler.TopChannels)
|
||||
r.Get("/api/analytics/summary", analyticsHandler.Summary)
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func analyticsRequest(t *testing.T, router chi.Router, method, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("X-Owner-ID", "1")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestAnalyticsTimeline(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
|
||||
seedAnalyticsAgent(t, db, "agent-b", "Agent B", 1)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Seed messages at various times within the last 24h
|
||||
for i := 0; i < 5; i++ {
|
||||
seedAnalyticsMessage(t, db, "agent-a", "agent-b", fmt.Sprintf("msg-%d", i), nil, now.Add(-time.Duration(i)*time.Hour))
|
||||
}
|
||||
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
span string
|
||||
wantStatus int
|
||||
wantNonEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "default span (24h)",
|
||||
span: "",
|
||||
wantStatus: http.StatusOK,
|
||||
wantNonEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "1h span",
|
||||
span: "1h",
|
||||
wantStatus: http.StatusOK,
|
||||
wantNonEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "4h span",
|
||||
span: "4h",
|
||||
wantStatus: http.StatusOK,
|
||||
wantNonEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "24h span",
|
||||
span: "24h",
|
||||
wantStatus: http.StatusOK,
|
||||
wantNonEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "7d span",
|
||||
span: "7d",
|
||||
wantStatus: http.StatusOK,
|
||||
wantNonEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "30d span",
|
||||
span: "30d",
|
||||
wantStatus: http.StatusOK,
|
||||
wantNonEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "invalid span",
|
||||
span: "99x",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := "/api/analytics/timeline"
|
||||
if tt.span != "" {
|
||||
path += "?span=" + tt.span
|
||||
}
|
||||
|
||||
rr := analyticsRequest(t, router, "GET", path)
|
||||
if rr.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, tt.wantStatus, rr.Body.String())
|
||||
}
|
||||
|
||||
if tt.wantStatus != http.StatusOK {
|
||||
return
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Span string `json:"span"`
|
||||
Buckets []struct {
|
||||
Time string `json:"time"`
|
||||
Count int `json:"count"`
|
||||
} `json:"buckets"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
expectedSpan := tt.span
|
||||
if expectedSpan == "" {
|
||||
expectedSpan = "24h"
|
||||
}
|
||||
if resp.Span != expectedSpan {
|
||||
t.Errorf("span = %q, want %q", resp.Span, expectedSpan)
|
||||
}
|
||||
|
||||
if tt.wantNonEmpty && resp.Total == 0 {
|
||||
t.Error("expected non-zero total")
|
||||
}
|
||||
|
||||
// Verify total matches sum of bucket counts
|
||||
bucketSum := 0
|
||||
for _, b := range resp.Buckets {
|
||||
bucketSum += b.Count
|
||||
}
|
||||
if bucketSum != resp.Total {
|
||||
t.Errorf("bucket sum %d != total %d", bucketSum, resp.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsTimeline_EmptyDB(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/timeline?span=24h")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Span string `json:"span"`
|
||||
Buckets []any `json:"buckets"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if resp.Total != 0 {
|
||||
t.Errorf("total = %d, want 0", resp.Total)
|
||||
}
|
||||
if len(resp.Buckets) != 0 {
|
||||
t.Errorf("buckets = %d, want 0", len(resp.Buckets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsTopAgents(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
seedAnalyticsAgent(t, db, "agent-alpha", "Alpha Agent", 1)
|
||||
seedAnalyticsAgent(t, db, "agent-beta", "Beta Agent", 1)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// agent-alpha sends 5 messages, agent-beta sends 2
|
||||
for i := 0; i < 5; i++ {
|
||||
seedAnalyticsMessage(t, db, "agent-alpha", "agent-beta", fmt.Sprintf("msg-%d", i), nil, now.Add(-time.Duration(i)*time.Minute))
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
seedAnalyticsMessage(t, db, "agent-beta", "agent-alpha", fmt.Sprintf("reply-%d", i), nil, now.Add(-time.Duration(i)*time.Minute))
|
||||
}
|
||||
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
t.Run("returns agents sorted by count", func(t *testing.T) {
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Span string `json:"span"`
|
||||
Agents []struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Count int `json:"count"`
|
||||
} `json:"agents"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Agents) < 2 {
|
||||
t.Fatalf("agents = %d, want >= 2", len(resp.Agents))
|
||||
}
|
||||
|
||||
// First agent should be alpha (5 messages)
|
||||
if resp.Agents[0].Name != "agent-alpha" {
|
||||
t.Errorf("top agent = %q, want agent-alpha", resp.Agents[0].Name)
|
||||
}
|
||||
if resp.Agents[0].Count != 5 {
|
||||
t.Errorf("top agent count = %d, want 5", resp.Agents[0].Count)
|
||||
}
|
||||
if resp.Agents[0].DisplayName != "Alpha Agent" {
|
||||
t.Errorf("display_name = %q, want 'Alpha Agent'", resp.Agents[0].DisplayName)
|
||||
}
|
||||
|
||||
// Second agent should be beta (2 messages)
|
||||
if resp.Agents[1].Name != "agent-beta" {
|
||||
t.Errorf("second agent = %q, want agent-beta", resp.Agents[1].Name)
|
||||
}
|
||||
if resp.Agents[1].Count != 2 {
|
||||
t.Errorf("second agent count = %d, want 2", resp.Agents[1].Count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respects limit parameter", func(t *testing.T) {
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h&limit=1")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Agents []struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
} `json:"agents"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Agents) != 1 {
|
||||
t.Errorf("agents = %d, want 1", len(resp.Agents))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid span returns error", func(t *testing.T) {
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=invalid")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnalyticsTopAgents_EmptyDB(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Agents []any `json:"agents"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Agents) != 0 {
|
||||
t.Errorf("agents = %d, want 0", len(resp.Agents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsTopChannels(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
|
||||
|
||||
chID1 := seedAnalyticsChannel(t, db, "news-mcp", "agent-a")
|
||||
chID2 := seedAnalyticsChannel(t, db, "general", "agent-a")
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// news-mcp gets 4 messages, general gets 2
|
||||
for i := 0; i < 4; i++ {
|
||||
seedAnalyticsMessage(t, db, "agent-a", "", fmt.Sprintf("ch-msg-%d", i), &chID1, now.Add(-time.Duration(i)*time.Minute))
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
seedAnalyticsMessage(t, db, "agent-a", "", fmt.Sprintf("gen-msg-%d", i), &chID2, now.Add(-time.Duration(i)*time.Minute))
|
||||
}
|
||||
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
t.Run("returns channels sorted by count", func(t *testing.T) {
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Span string `json:"span"`
|
||||
Channels []struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
} `json:"channels"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Channels) < 2 {
|
||||
t.Fatalf("channels = %d, want >= 2", len(resp.Channels))
|
||||
}
|
||||
|
||||
if resp.Channels[0].Name != "news-mcp" {
|
||||
t.Errorf("top channel = %q, want news-mcp", resp.Channels[0].Name)
|
||||
}
|
||||
if resp.Channels[0].Count != 4 {
|
||||
t.Errorf("top channel count = %d, want 4", resp.Channels[0].Count)
|
||||
}
|
||||
|
||||
if resp.Channels[1].Name != "general" {
|
||||
t.Errorf("second channel = %q, want general", resp.Channels[1].Name)
|
||||
}
|
||||
if resp.Channels[1].Count != 2 {
|
||||
t.Errorf("second channel count = %d, want 2", resp.Channels[1].Count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respects limit parameter", func(t *testing.T) {
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h&limit=1")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Channels []struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
} `json:"channels"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Channels) != 1 {
|
||||
t.Errorf("channels = %d, want 1", len(resp.Channels))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid span returns error", func(t *testing.T) {
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=invalid")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnalyticsTopChannels_EmptyDB(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Channels []any `json:"channels"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Channels) != 0 {
|
||||
t.Errorf("channels = %d, want 0", len(resp.Channels))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsSummary(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
|
||||
seedAnalyticsAgent(t, db, "agent-b", "Agent B", 1)
|
||||
|
||||
chID := seedAnalyticsChannel(t, db, "test-channel", "agent-a")
|
||||
|
||||
now := time.Now().UTC()
|
||||
seedAnalyticsMessage(t, db, "agent-a", "agent-b", "hello", nil, now)
|
||||
seedAnalyticsMessage(t, db, "agent-b", "agent-a", "hi back", nil, now)
|
||||
seedAnalyticsMessage(t, db, "agent-a", "", "channel msg", &chID, now)
|
||||
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/summary")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
TotalAgents int `json:"total_agents"`
|
||||
TotalChannels int `json:"total_channels"`
|
||||
TotalMessages int `json:"total_messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if resp.TotalAgents != 2 {
|
||||
t.Errorf("total_agents = %d, want 2", resp.TotalAgents)
|
||||
}
|
||||
if resp.TotalChannels != 1 {
|
||||
t.Errorf("total_channels = %d, want 1", resp.TotalChannels)
|
||||
}
|
||||
if resp.TotalMessages != 3 {
|
||||
t.Errorf("total_messages = %d, want 3", resp.TotalMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsSummary_EmptyDB(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
rr := analyticsRequest(t, router, "GET", "/api/analytics/summary")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
TotalAgents int `json:"total_agents"`
|
||||
TotalChannels int `json:"total_channels"`
|
||||
TotalMessages int `json:"total_messages"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if resp.TotalAgents != 0 {
|
||||
t.Errorf("total_agents = %d, want 0", resp.TotalAgents)
|
||||
}
|
||||
if resp.TotalChannels != 0 {
|
||||
t.Errorf("total_channels = %d, want 0", resp.TotalChannels)
|
||||
}
|
||||
if resp.TotalMessages != 0 {
|
||||
t.Errorf("total_messages = %d, want 0", resp.TotalMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalytics_Unauthenticated(t *testing.T) {
|
||||
db := newAnalyticsTestDB(t)
|
||||
router := setupAnalyticsRouter(t, db)
|
||||
|
||||
endpoints := []string{
|
||||
"/api/analytics/timeline",
|
||||
"/api/analytics/top-agents",
|
||||
"/api/analytics/top-channels",
|
||||
"/api/analytics/summary",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", endpoint, nil)
|
||||
// No X-Owner-ID header
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
)
|
||||
|
||||
// NewMessageEvent is broadcast when a new message is sent.
|
||||
@@ -107,3 +108,24 @@ func (b *SSEBroadcaster) BroadcastChannelMessage(ctx context.Context, channelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnMessageSent implements messaging.MessageListener so the SSEBroadcaster
|
||||
// can be wired directly into the messaging service. This ensures SSE events
|
||||
// fire for messages sent via MCP (agents) as well as the REST API.
|
||||
func (b *SSEBroadcaster) OnMessageSent(ctx context.Context, msg *messaging.Message) {
|
||||
event := NewMessageEvent{
|
||||
MessageID: msg.ID,
|
||||
FromAgent: msg.FromAgent,
|
||||
ToAgent: msg.ToAgent,
|
||||
}
|
||||
|
||||
if msg.ChannelID != nil {
|
||||
ch, err := b.channelService.GetChannel(ctx, *msg.ChannelID)
|
||||
if err == nil {
|
||||
event.Channel = ch.Name
|
||||
}
|
||||
b.BroadcastChannelMessage(ctx, *msg.ChannelID, event)
|
||||
} else {
|
||||
b.BroadcastDM(ctx, event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -315,23 +316,8 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast real-time event to connected SSE clients
|
||||
if h.broadcaster != nil {
|
||||
event := NewMessageEvent{
|
||||
MessageID: msg.ID,
|
||||
FromAgent: msg.FromAgent,
|
||||
ToAgent: msg.ToAgent,
|
||||
}
|
||||
if msg.ChannelID != nil && h.broadcaster.channelService != nil {
|
||||
ch, chErr := h.broadcaster.channelService.GetChannel(r.Context(), *msg.ChannelID)
|
||||
if chErr == nil {
|
||||
event.Channel = ch.Name
|
||||
}
|
||||
h.broadcaster.BroadcastChannelMessage(r.Context(), *msg.ChannelID, event)
|
||||
} else {
|
||||
h.broadcaster.BroadcastDM(r.Context(), event)
|
||||
}
|
||||
}
|
||||
// SSE broadcast is handled by the MessageListener on the messaging
|
||||
// service, so it fires for both REST and MCP message paths.
|
||||
|
||||
writeJSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
@@ -414,9 +400,60 @@ func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request)
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Parse advanced filter parameters
|
||||
channelFilter := r.URL.Query().Get("channel")
|
||||
agentFilter := r.URL.Query().Get("agent")
|
||||
afterFilter := r.URL.Query().Get("after")
|
||||
beforeFilter := r.URL.Query().Get("before")
|
||||
|
||||
var allMessages []*messaging.Message
|
||||
for _, agent := range ownedAgents {
|
||||
opts := messaging.SearchOptions{Limit: limit}
|
||||
opts := messaging.SearchOptions{
|
||||
Limit: limit,
|
||||
After: afterFilter,
|
||||
Before: beforeFilter,
|
||||
}
|
||||
|
||||
// Apply channel filters (comma-separated, prefix with - to exclude)
|
||||
if channelFilter != "" {
|
||||
channels := strings.Split(channelFilter, ",")
|
||||
var includeChannels []string
|
||||
var excludeChannels []string
|
||||
for _, ch := range channels {
|
||||
ch = strings.TrimSpace(ch)
|
||||
if ch == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(ch, "-") {
|
||||
excludeChannels = append(excludeChannels, strings.TrimPrefix(ch, "-"))
|
||||
} else {
|
||||
includeChannels = append(includeChannels, ch)
|
||||
}
|
||||
}
|
||||
opts.Channels = includeChannels
|
||||
opts.ExcludeChannels = excludeChannels
|
||||
}
|
||||
|
||||
// Apply agent filters (comma-separated, prefix with - to exclude)
|
||||
if agentFilter != "" {
|
||||
agentNames := strings.Split(agentFilter, ",")
|
||||
var includeAgents []string
|
||||
var excludeAgents []string
|
||||
for _, a := range agentNames {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
excludeAgents = append(excludeAgents, strings.TrimPrefix(a, "-"))
|
||||
} else {
|
||||
includeAgents = append(includeAgents, a)
|
||||
}
|
||||
}
|
||||
opts.Agents = includeAgents
|
||||
opts.ExcludeAgents = excludeAgents
|
||||
}
|
||||
|
||||
result, err := h.msgService.SearchMessages(r.Context(), agent.Name, query, opts)
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -333,7 +333,7 @@ func TestChannelMessages_IncludesLastRead(t *testing.T) {
|
||||
}
|
||||
|
||||
// Broadcast messages
|
||||
msgs, err := channelService.BroadcastMessage(ctx, ch.ID, "human-agent", "Hello channel", 5, "")
|
||||
msgs, err := channelService.BroadcastMessage(ctx, ch.ID, "human-agent", "Hello channel", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("broadcast: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/push"
|
||||
)
|
||||
|
||||
// PushHandler manages Web Push notification subscription endpoints.
|
||||
type PushHandler struct {
|
||||
pushService *push.Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewPushHandler creates a new push notification handler.
|
||||
func NewPushHandler(pushService *push.Service) *PushHandler {
|
||||
return &PushHandler{
|
||||
pushService: pushService,
|
||||
logger: slog.Default().With("component", "api.push"),
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeRequest is the JSON body for POST /api/push/subscribe.
|
||||
type subscribeRequest struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
KeyP256dh string `json:"key_p256dh"`
|
||||
KeyAuth string `json:"key_auth"`
|
||||
}
|
||||
|
||||
// unsubscribeRequest is the JSON body for DELETE /api/push/subscribe.
|
||||
type unsubscribeRequest struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// Subscribe handles POST /api/push/subscribe.
|
||||
// Registers a Web Push subscription for the authenticated user.
|
||||
func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
var req subscribeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Endpoint == "" || req.KeyP256dh == "" || req.KeyAuth == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "endpoint, key_p256dh, and key_auth are required"))
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
|
||||
if err := h.pushService.Subscribe(r.Context(), ownerID, req.Endpoint, req.KeyP256dh, req.KeyAuth, userAgent); err != nil {
|
||||
h.logger.Error("push subscribe failed",
|
||||
"user_id", ownerID,
|
||||
"error", err,
|
||||
)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to register subscription"))
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("push subscription registered",
|
||||
"user_id", ownerID,
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "subscribed"})
|
||||
}
|
||||
|
||||
// Unsubscribe handles DELETE /api/push/subscribe.
|
||||
// Removes a Web Push subscription for the authenticated user.
|
||||
func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
var req unsubscribeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Endpoint == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "endpoint is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.pushService.Unsubscribe(r.Context(), ownerID, req.Endpoint); err != nil {
|
||||
h.logger.Error("push unsubscribe failed",
|
||||
"error", err,
|
||||
)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to remove subscription"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unsubscribed"})
|
||||
}
|
||||
|
||||
// VAPIDKey handles GET /api/push/vapid-key.
|
||||
// Returns the VAPID public key needed by clients to subscribe to push notifications.
|
||||
func (h *PushHandler) VAPIDKey(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"vapid_public_key": h.pushService.GetVAPIDPublicKey(),
|
||||
})
|
||||
}
|
||||
+42
-3
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/push"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
)
|
||||
@@ -30,8 +32,12 @@ type RouterConfig struct {
|
||||
WebhookStore webhooks.WebhookStore
|
||||
K8sService *k8s.K8sService
|
||||
K8sStore k8s.K8sStore
|
||||
PushService *push.Service
|
||||
SSEHub *SSEHub
|
||||
Broadcaster *SSEBroadcaster
|
||||
SessionMiddleware func(http.Handler) http.Handler
|
||||
DB *sql.DB
|
||||
Version string
|
||||
}
|
||||
|
||||
// NewRouter creates a chi router with all API routes configured.
|
||||
@@ -88,9 +94,10 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
notificationsHandler := NewNotificationsHandler(cfg.MsgService, cfg.AgentService, cfg.ChannelService)
|
||||
|
||||
// Wire up SSE broadcaster for real-time events
|
||||
if cfg.SSEHub != nil {
|
||||
broadcaster := NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService)
|
||||
messagesHandler.SetBroadcaster(broadcaster)
|
||||
if cfg.Broadcaster != nil {
|
||||
messagesHandler.SetBroadcaster(cfg.Broadcaster)
|
||||
} else if cfg.SSEHub != nil {
|
||||
messagesHandler.SetBroadcaster(NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService))
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -194,6 +201,38 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
r.Get("/api/k8s/job-runs/{id}/logs", k8sHandler.JobRunLogs)
|
||||
})
|
||||
}
|
||||
|
||||
// Push Notifications
|
||||
if cfg.PushService != nil {
|
||||
pushHandler := NewPushHandler(cfg.PushService)
|
||||
// VAPID key endpoint is unauthenticated (needed before subscription)
|
||||
r.Get("/api/push/vapid-key", pushHandler.VAPIDKey)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
|
||||
r.Post("/api/push/subscribe", pushHandler.Subscribe)
|
||||
r.Delete("/api/push/subscribe", pushHandler.Unsubscribe)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Analytics (authenticated, requires DB)
|
||||
if cfg.DB != nil {
|
||||
analyticsHandler := NewAnalyticsHandler(cfg.DB, cfg.AgentService, cfg.ChannelService)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
|
||||
r.Get("/api/analytics/timeline", analyticsHandler.Timeline)
|
||||
r.Get("/api/analytics/top-agents", analyticsHandler.TopAgents)
|
||||
r.Get("/api/analytics/top-channels", analyticsHandler.TopChannels)
|
||||
r.Get("/api/analytics/summary", analyticsHandler.Summary)
|
||||
})
|
||||
}
|
||||
|
||||
// Version (unauthenticated)
|
||||
if cfg.Version != "" {
|
||||
versionHandler := NewVersionHandler(cfg.Version)
|
||||
r.Get("/api/version", versionHandler.GetVersion)
|
||||
}
|
||||
|
||||
// Metrics endpoint (unauthenticated, only registered when enabled)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// VersionHandler serves version information.
|
||||
// The version is set at build time via -ldflags.
|
||||
type VersionHandler struct {
|
||||
version string
|
||||
}
|
||||
|
||||
// NewVersionHandler creates a new version handler.
|
||||
func NewVersionHandler(version string) *VersionHandler {
|
||||
return &VersionHandler{version: version}
|
||||
}
|
||||
|
||||
// GetVersion handles GET /api/version.
|
||||
func (h *VersionHandler) GetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"version": h.version,
|
||||
"repo": "https://github.com/synapbus/synapbus",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetVersion(t *testing.T) {
|
||||
handler := NewVersionHandler("v0.7.0")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/version", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.GetVersion(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
ct := rr.Header().Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Version string `json:"version"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if resp.Version != "v0.7.0" {
|
||||
t.Errorf("version = %q, want v0.7.0", resp.Version)
|
||||
}
|
||||
if resp.Repo != "https://github.com/synapbus/synapbus" {
|
||||
t.Errorf("repo = %q, want https://github.com/synapbus/synapbus", resp.Repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVersion_DevBuild(t *testing.T) {
|
||||
handler := NewVersionHandler("dev")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/version", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.GetVersion(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Version string `json:"version"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if resp.Version != "dev" {
|
||||
t.Errorf("version = %q, want dev", resp.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVersion_ResponseFormat(t *testing.T) {
|
||||
handler := NewVersionHandler("v1.2.3")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/version", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.GetVersion(rr, req)
|
||||
|
||||
// Verify the response is valid JSON with exactly the expected keys
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(raw) != 2 {
|
||||
t.Errorf("response has %d keys, want 2", len(raw))
|
||||
}
|
||||
|
||||
if _, ok := raw["version"]; !ok {
|
||||
t.Error("response missing 'version' key")
|
||||
}
|
||||
if _, ok := raw["repo"]; !ok {
|
||||
t.Error("response missing 'repo' key")
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,59 @@ func (h *Handlers) HandleMe(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// HandleUpdateProfile handles PUT /api/auth/profile.
|
||||
func (h *Handlers) HandleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "PUT required")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.DisplayName) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Display name cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userStore.UpdateDisplayName(r.Context(), user.ID, req.DisplayName); err != nil {
|
||||
h.logger.Error("update display name failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "server_error", "Failed to update display name")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch updated user
|
||||
updated, err := h.userStore.GetUserByID(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("fetch updated user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "server_error", "Profile updated but failed to fetch result")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"message": "Profile updated",
|
||||
"user": map[string]any{
|
||||
"id": updated.ID,
|
||||
"username": updated.Username,
|
||||
"display_name": updated.DisplayName,
|
||||
"role": updated.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleChangePassword handles PUT /auth/password.
|
||||
func (h *Handlers) HandleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
@@ -295,13 +348,21 @@ func (h *Handlers) HandleOAuthMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
baseURL := h.config.IssuerURL
|
||||
if baseURL == "" {
|
||||
// Fall back to request Host header
|
||||
if baseURL == "" || baseURL == "auto" {
|
||||
// Derive from request headers (supports reverse proxies and tunnels)
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||
// Cloudflare, nginx, and other proxies set these headers
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
host := r.Host
|
||||
if fwdHost := r.Header.Get("X-Forwarded-Host"); fwdHost != "" {
|
||||
host = fwdHost
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
@@ -816,13 +877,28 @@ const authorizeTemplateHTML = `<!DOCTYPE html>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.8">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="#36c5f0"/>
|
||||
<path d="M2 17l10 5 10-5" stroke="#7c3aed"/>
|
||||
<path d="M2 12l10 5 10-5" stroke="url(#g)"/>
|
||||
<defs><linearGradient id="g" x1="2" y1="12" x2="22" y2="17" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#36c5f0"/><stop offset="1" stop-color="#7c3aed"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs><linearGradient id="bg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7c3aed"/><stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient></defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
|
||||
<g transform="translate(4,4)">
|
||||
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd"/>
|
||||
<circle cx="6" cy="10" r="1.5" fill="#a78bfa"/>
|
||||
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd"/>
|
||||
<circle cx="12" cy="13" r="2" fill="#67e8f9"/>
|
||||
<circle cx="5" cy="17" r="1.5" fill="#a78bfa"/>
|
||||
<circle cx="19" cy="17" r="1.3" fill="#a78bfa"/>
|
||||
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd"/>
|
||||
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3"/>
|
||||
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1>SynapBus</h1>
|
||||
<p>Agent Authorization</p>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadConfig reads environment variables and returns a slice of enabled identity providers.
|
||||
// Providers are only included if both client ID and client secret are set.
|
||||
func LoadConfig(baseURL string) []Provider {
|
||||
var providers []Provider
|
||||
|
||||
// GitHub
|
||||
if clientID, clientSecret := os.Getenv("SYNAPBUS_IDP_GITHUB_CLIENT_ID"), os.Getenv("SYNAPBUS_IDP_GITHUB_CLIENT_SECRET"); clientID != "" && clientSecret != "" {
|
||||
redirectURL := baseURL + "/auth/callback/github"
|
||||
providers = append(providers, NewGitHubProvider(clientID, clientSecret, redirectURL))
|
||||
slog.Info("IdP enabled", "provider", "github")
|
||||
}
|
||||
|
||||
// Google (OIDC)
|
||||
if clientID, clientSecret := os.Getenv("SYNAPBUS_IDP_GOOGLE_CLIENT_ID"), os.Getenv("SYNAPBUS_IDP_GOOGLE_CLIENT_SECRET"); clientID != "" && clientSecret != "" {
|
||||
var allowedDomains []string
|
||||
if domains := os.Getenv("SYNAPBUS_IDP_GOOGLE_ALLOWED_DOMAINS"); domains != "" {
|
||||
for _, d := range strings.Split(domains, ",") {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
allowedDomains = append(allowedDomains, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
p, err := NewOIDCProvider(ctx, OIDCConfig{
|
||||
ID: "google",
|
||||
DisplayName: "Google",
|
||||
IssuerURL: "https://accounts.google.com",
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: baseURL + "/auth/callback/google",
|
||||
AllowedDomains: allowedDomains,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize Google OIDC provider", "error", err)
|
||||
} else {
|
||||
providers = append(providers, p)
|
||||
slog.Info("IdP enabled", "provider", "google", "allowed_domains", allowedDomains)
|
||||
}
|
||||
}
|
||||
|
||||
// Azure AD (OIDC)
|
||||
if clientID, clientSecret := os.Getenv("SYNAPBUS_IDP_AZUREAD_CLIENT_ID"), os.Getenv("SYNAPBUS_IDP_AZUREAD_CLIENT_SECRET"); clientID != "" && clientSecret != "" {
|
||||
tenantID := os.Getenv("SYNAPBUS_IDP_AZUREAD_TENANT_ID")
|
||||
if tenantID == "" {
|
||||
tenantID = "common" // multi-tenant by default
|
||||
}
|
||||
|
||||
var groupMapping map[string]string
|
||||
if gm := os.Getenv("SYNAPBUS_IDP_AZUREAD_GROUP_MAPPING"); gm != "" {
|
||||
if err := json.Unmarshal([]byte(gm), &groupMapping); err != nil {
|
||||
slog.Error("failed to parse Azure AD group mapping", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
issuerURL := "https://login.microsoftonline.com/" + tenantID + "/v2.0"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
p, err := NewOIDCProvider(ctx, OIDCConfig{
|
||||
ID: "azuread",
|
||||
DisplayName: "Microsoft",
|
||||
IssuerURL: issuerURL,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: baseURL + "/auth/callback/azuread",
|
||||
GroupMapping: groupMapping,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize Azure AD OIDC provider", "error", err)
|
||||
} else {
|
||||
providers = append(providers, p)
|
||||
slog.Info("IdP enabled", "provider", "azuread", "tenant_id", tenantID)
|
||||
}
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/github"
|
||||
)
|
||||
|
||||
// GitHubProvider implements GitHub OAuth authentication.
|
||||
// GitHub does not support OIDC discovery, so this uses plain OAuth 2.0
|
||||
// with GitHub's user API endpoints.
|
||||
type GitHubProvider struct {
|
||||
config *oauth2.Config
|
||||
}
|
||||
|
||||
// NewGitHubProvider creates a new GitHub OAuth provider.
|
||||
func NewGitHubProvider(clientID, clientSecret, redirectURL string) *GitHubProvider {
|
||||
return &GitHubProvider{
|
||||
config: &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{"read:user", "user:email"},
|
||||
Endpoint: github.Endpoint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) ID() string { return "github" }
|
||||
func (p *GitHubProvider) Type() string { return "oauth" }
|
||||
func (p *GitHubProvider) DisplayName() string { return "GitHub" }
|
||||
|
||||
func (p *GitHubProvider) AuthCodeURL(state string) string {
|
||||
return p.config.AuthCodeURL(state)
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) Exchange(ctx context.Context, code string) (*ExternalUser, error) {
|
||||
token, err := p.config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("github: exchange code: %w", err)
|
||||
}
|
||||
|
||||
client := p.config.Client(ctx, token)
|
||||
|
||||
// Fetch user profile
|
||||
userInfo, err := fetchGitHubUser(client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch primary verified email
|
||||
email, err := fetchGitHubPrimaryEmail(client)
|
||||
if err != nil {
|
||||
// Non-fatal: email might not be available
|
||||
email = ""
|
||||
}
|
||||
|
||||
// If user profile has an email and we didn't get one from the emails endpoint, use it
|
||||
if email == "" {
|
||||
if e, ok := userInfo["email"].(string); ok && e != "" {
|
||||
email = e
|
||||
}
|
||||
}
|
||||
|
||||
idNum, _ := userInfo["id"].(float64)
|
||||
login, _ := userInfo["login"].(string)
|
||||
name, _ := userInfo["name"].(string)
|
||||
|
||||
return &ExternalUser{
|
||||
ProviderID: "github",
|
||||
ExternalID: strconv.FormatInt(int64(idNum), 10),
|
||||
Email: email,
|
||||
Name: name,
|
||||
Username: login,
|
||||
RawClaims: userInfo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fetchGitHubUser(client *http.Client) (map[string]any, error) {
|
||||
resp, err := client.Get("https://api.github.com/user")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("github: fetch user: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("github: user API returned %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var user map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("github: decode user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// githubEmail represents an email entry from the GitHub emails API.
|
||||
type githubEmail struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
func fetchGitHubPrimaryEmail(client *http.Client) (string, error) {
|
||||
resp, err := client.Get("https://api.github.com/user/emails")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("github: fetch emails: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("github: emails API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var emails []githubEmail
|
||||
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil {
|
||||
return "", fmt.Errorf("github: decode emails: %w", err)
|
||||
}
|
||||
|
||||
// Find primary verified email
|
||||
for _, e := range emails {
|
||||
if e.Primary && e.Verified {
|
||||
return e.Email, nil
|
||||
}
|
||||
}
|
||||
// Fallback: first verified email
|
||||
for _, e := range emails {
|
||||
if e.Verified {
|
||||
return e.Email, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("github: no verified email found")
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/synapbus/synapbus/internal/auth"
|
||||
)
|
||||
|
||||
// AgentProvisioner creates a human agent and personal channel after IdP login.
|
||||
// This is a narrow interface to avoid importing the agents/channels packages.
|
||||
type AgentProvisioner interface {
|
||||
ProvisionHumanAgent(ctx context.Context, username, displayName string, ownerID int64) error
|
||||
}
|
||||
|
||||
// Handlers holds HTTP handlers for external identity provider authentication.
|
||||
type Handlers struct {
|
||||
providers map[string]Provider
|
||||
providerList []Provider // preserve order for listing
|
||||
idStore *UserIdentityStore
|
||||
userStore auth.UserStore
|
||||
sessionStore auth.SessionStore
|
||||
agentProvisioner AgentProvisioner
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewHandlers creates a new set of IdP HTTP handlers.
|
||||
func NewHandlers(
|
||||
providers []Provider,
|
||||
idStore *UserIdentityStore,
|
||||
userStore auth.UserStore,
|
||||
sessionStore auth.SessionStore,
|
||||
agentProvisioner AgentProvisioner,
|
||||
) *Handlers {
|
||||
providerMap := make(map[string]Provider, len(providers))
|
||||
for _, p := range providers {
|
||||
providerMap[p.ID()] = p
|
||||
}
|
||||
return &Handlers{
|
||||
providers: providerMap,
|
||||
providerList: providers,
|
||||
idStore: idStore,
|
||||
userStore: userStore,
|
||||
sessionStore: sessionStore,
|
||||
agentProvisioner: agentProvisioner,
|
||||
logger: slog.Default().With("component", "idp"),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListProviders returns the list of enabled identity providers.
|
||||
// GET /auth/providers
|
||||
func (h *Handlers) HandleListProviders(w http.ResponseWriter, r *http.Request) {
|
||||
providers := make([]ProviderInfo, 0, len(h.providerList))
|
||||
for _, p := range h.providerList {
|
||||
providers = append(providers, ProviderInfo{
|
||||
ID: p.ID(),
|
||||
Type: p.Type(),
|
||||
DisplayName: p.DisplayName(),
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"providers": providers,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleLogin initiates the OAuth/OIDC flow by redirecting to the IdP.
|
||||
// GET /auth/login/{provider}
|
||||
func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
providerID := chi.URLParam(r, "provider")
|
||||
provider, ok := h.providers[providerID]
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"unknown_provider","message":"Provider %q not found"}`, providerID), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
h.logger.Error("failed to generate state", "error", err)
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to generate state"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Store state in a short-lived cookie for CSRF protection
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "idp_state",
|
||||
Value: state,
|
||||
Path: "/auth/callback/" + providerID,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 600, // 10 minutes
|
||||
})
|
||||
|
||||
authURL := provider.AuthCodeURL(state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// HandleCallback processes the OAuth/OIDC callback from the IdP.
|
||||
// GET /auth/callback/{provider}
|
||||
func (h *Handlers) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
providerID := chi.URLParam(r, "provider")
|
||||
provider, ok := h.providers[providerID]
|
||||
if !ok {
|
||||
http.Error(w, "Unknown provider", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state parameter
|
||||
stateCookie, err := r.Cookie("idp_state")
|
||||
if err != nil || stateCookie.Value == "" {
|
||||
http.Error(w, "Missing state cookie", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("state") != stateCookie.Value {
|
||||
http.Error(w, "State mismatch — possible CSRF attack", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear state cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "idp_state",
|
||||
Value: "",
|
||||
Path: "/auth/callback/" + providerID,
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
// Check for error from IdP
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
desc := r.URL.Query().Get("error_description")
|
||||
h.logger.Warn("IdP returned error", "provider", providerID, "error", errParam, "description", desc)
|
||||
http.Redirect(w, r, "/login?error="+errParam, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "Missing authorization code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for user info
|
||||
extUser, err := provider.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
h.logger.Error("IdP exchange failed", "provider", providerID, "error", err)
|
||||
http.Redirect(w, r, "/login?error=exchange_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Find or create local user
|
||||
user, err := h.findOrCreateUser(r.Context(), extUser)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to provision user from IdP", "provider", providerID, "error", err)
|
||||
http.Redirect(w, r, "/login?error=provisioning_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
session, err := h.sessionStore.CreateSession(r.Context(), user.ID, 24*time.Hour)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create session after IdP login", "error", err)
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: session.SessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400, // 24 hours
|
||||
})
|
||||
|
||||
auth.LogAuthEvent(r.Context(), h.logger, auth.AuthEvent{
|
||||
Type: auth.EventLoginSuccess,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
RemoteIP: r.RemoteAddr,
|
||||
Details: map[string]any{
|
||||
"provider": providerID,
|
||||
"external_id": extUser.ExternalID,
|
||||
},
|
||||
})
|
||||
|
||||
// Ensure human agent and channel in background
|
||||
if h.agentProvisioner != nil {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if err := h.agentProvisioner.ProvisionHumanAgent(ctx, user.Username, user.DisplayName, user.ID); err != nil {
|
||||
h.logger.Warn("failed to ensure human agent after IdP login", "username", user.Username, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Redirect to home
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// findOrCreateUser looks up or provisions a local user from external identity.
|
||||
// 1. Check user_identities for (provider, external_id) -> if found, load user
|
||||
// 2. If not found, check users by email -> if found, link identity
|
||||
// 3. If no user at all, create new user with random password
|
||||
func (h *Handlers) findOrCreateUser(ctx context.Context, ext *ExternalUser) (*auth.User, error) {
|
||||
// Step 1: Check existing identity link
|
||||
userID, err := h.idStore.FindByProvider(ctx, ext.ProviderID, ext.ExternalID)
|
||||
if err == nil {
|
||||
// Found existing link
|
||||
user, err := h.userStore.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load linked user %d: %w", userID, err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("find identity: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Try to find user by email
|
||||
if ext.Email != "" {
|
||||
user, err := h.userStore.GetUserByEmail(ctx, ext.Email)
|
||||
if err == nil {
|
||||
// Found user with matching email — link this identity
|
||||
if linkErr := h.idStore.Create(ctx, user.ID, ext.ProviderID, ext.ExternalID, ext.Email, ext.Name, ext.RawClaims); linkErr != nil {
|
||||
h.logger.Warn("failed to link identity to existing user", "user_id", user.ID, "error", linkErr)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
// If error is not "not found", it's a real error
|
||||
if err != auth.ErrUserNotFound {
|
||||
return nil, fmt.Errorf("find user by email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Create new user
|
||||
username := sanitizeUsername(ext.Username, ext.ProviderID, ext.ExternalID)
|
||||
displayName := ext.Name
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
|
||||
// Generate random password (user will login via IdP)
|
||||
randomPW, err := generateRandomPassword()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
|
||||
user, err := h.userStore.CreateUser(ctx, username, randomPW, displayName)
|
||||
if err != nil {
|
||||
// If username conflicts, try with a suffix
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
suffix, _ := generateShortID()
|
||||
username = username + "_" + suffix
|
||||
user, err = h.userStore.CreateUser(ctx, username, randomPW, displayName)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set email on the user if available
|
||||
if ext.Email != "" {
|
||||
if emailStore, ok := h.userStore.(*auth.SQLiteUserStore); ok {
|
||||
emailStore.SetEmail(ctx, user.ID, ext.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// Link identity
|
||||
if linkErr := h.idStore.Create(ctx, user.ID, ext.ProviderID, ext.ExternalID, ext.Email, ext.Name, ext.RawClaims); linkErr != nil {
|
||||
h.logger.Warn("failed to link identity to new user", "user_id", user.ID, "error", linkErr)
|
||||
}
|
||||
|
||||
h.logger.Info("provisioned new user from IdP",
|
||||
"username", username,
|
||||
"provider", ext.ProviderID,
|
||||
"external_id", ext.ExternalID,
|
||||
"email", ext.Email,
|
||||
)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// sanitizeUsername normalizes a username from an IdP to match SynapBus requirements.
|
||||
// Usernames must be 3-64 chars, alphanumeric and underscore only.
|
||||
func sanitizeUsername(username, provider, externalID string) string {
|
||||
if username == "" {
|
||||
username = provider + "_" + externalID
|
||||
}
|
||||
|
||||
// Replace invalid characters with underscore
|
||||
var result strings.Builder
|
||||
for _, r := range username {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
result.WriteRune('_')
|
||||
}
|
||||
}
|
||||
username = result.String()
|
||||
|
||||
// Trim underscores from edges and ensure minimum length
|
||||
username = strings.Trim(username, "_")
|
||||
if len(username) < 3 {
|
||||
username = username + "_user"
|
||||
}
|
||||
if len(username) > 64 {
|
||||
username = username[:64]
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// generateState creates a cryptographically random state parameter for OAuth.
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// generateRandomPassword creates a random password for IdP-provisioned users.
|
||||
func generateRandomPassword() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// generateShortID creates a short random string for username disambiguation.
|
||||
func generateShortID() (string, error) {
|
||||
b := make([]byte, 3)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/auth"
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func newTestUserStore(db *sql.DB, t *testing.T) auth.UserStore {
|
||||
t.Helper()
|
||||
return auth.NewSQLiteUserStore(db, 10) // low cost for fast tests
|
||||
}
|
||||
|
||||
// --- GitHub provider tests ---
|
||||
|
||||
func TestGitHubProvider_AuthCodeURL(t *testing.T) {
|
||||
p := NewGitHubProvider("test-client-id", "test-secret", "http://localhost:8080/auth/callback/github")
|
||||
|
||||
url := p.AuthCodeURL("test-state-123")
|
||||
|
||||
if url == "" {
|
||||
t.Fatal("AuthCodeURL returned empty string")
|
||||
}
|
||||
if p.ID() != "github" {
|
||||
t.Errorf("ID() = %q, want %q", p.ID(), "github")
|
||||
}
|
||||
if p.Type() != "oauth" {
|
||||
t.Errorf("Type() = %q, want %q", p.Type(), "oauth")
|
||||
}
|
||||
if p.DisplayName() != "GitHub" {
|
||||
t.Errorf("DisplayName() = %q, want %q", p.DisplayName(), "GitHub")
|
||||
}
|
||||
|
||||
// URL should contain the client ID and state
|
||||
if got := url; got == "" {
|
||||
t.Error("expected non-empty URL")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OIDC domain validation tests ---
|
||||
|
||||
func TestOIDCProvider_DomainRestriction(t *testing.T) {
|
||||
p := &OIDCProvider{
|
||||
id: "test",
|
||||
displayName: "Test",
|
||||
allowedDomains: []string{"gcore.com", "example.com"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
hd string
|
||||
wantError bool
|
||||
}{
|
||||
{"allowed domain via hd", "user@gcore.com", "gcore.com", false},
|
||||
{"allowed domain via email", "user@example.com", "", false},
|
||||
{"disallowed domain", "user@evil.com", "evil.com", true},
|
||||
{"no domain info", "", "", true},
|
||||
{"case insensitive", "User@Gcore.COM", "Gcore.COM", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := p.validateDomain(tt.email, tt.hd)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("validateDomain(%q, %q) error = %v, wantError %v", tt.email, tt.hd, err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProvider_NoDomainRestriction(t *testing.T) {
|
||||
p := &OIDCProvider{
|
||||
id: "test",
|
||||
displayName: "Test",
|
||||
allowedDomains: nil,
|
||||
}
|
||||
|
||||
if len(p.allowedDomains) != 0 {
|
||||
t.Error("expected empty allowed domains")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Store tests ---
|
||||
|
||||
func TestUserIdentityStore_CreateAndFind(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewUserIdentityStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a user first
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)`,
|
||||
"testuser", "hash", "Test User", "user",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
var userID int64
|
||||
db.QueryRowContext(ctx, "SELECT id FROM users WHERE username = ?", "testuser").Scan(&userID)
|
||||
|
||||
// Create identity
|
||||
claims := map[string]any{"sub": "12345", "login": "ghuser"}
|
||||
err = store.Create(ctx, userID, "github", "12345", "test@example.com", "Test User", claims)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// Find by provider
|
||||
foundID, err := store.FindByProvider(ctx, "github", "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByProvider: %v", err)
|
||||
}
|
||||
if foundID != userID {
|
||||
t.Errorf("FindByProvider = %d, want %d", foundID, userID)
|
||||
}
|
||||
|
||||
// Find non-existent
|
||||
_, err = store.FindByProvider(ctx, "github", "99999")
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIdentityStore_ListByUser(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewUserIdentityStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)`,
|
||||
"multiuser", "hash", "Multi User", "user",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
var userID int64
|
||||
db.QueryRowContext(ctx, "SELECT id FROM users WHERE username = ?", "multiuser").Scan(&userID)
|
||||
|
||||
store.Create(ctx, userID, "github", "gh-123", "user@gh.com", "GH User", map[string]any{})
|
||||
store.Create(ctx, userID, "google", "goog-456", "user@google.com", "Google User", map[string]any{})
|
||||
|
||||
identities, err := store.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
if len(identities) != 2 {
|
||||
t.Errorf("got %d identities, want 2", len(identities))
|
||||
}
|
||||
|
||||
if identities[0].Provider != "github" {
|
||||
t.Errorf("first identity provider = %q, want %q", identities[0].Provider, "github")
|
||||
}
|
||||
if identities[1].Provider != "google" {
|
||||
t.Errorf("second identity provider = %q, want %q", identities[1].Provider, "google")
|
||||
}
|
||||
|
||||
empty, err := store.ListByUser(ctx, 99999)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser (empty): %v", err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Errorf("expected empty list, got %d", len(empty))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handler tests ---
|
||||
|
||||
func TestHandleListProviders(t *testing.T) {
|
||||
providers := []Provider{
|
||||
NewGitHubProvider("id", "secret", "http://localhost/callback"),
|
||||
&mockProvider{id: "google", providerType: "oidc", name: "Google"},
|
||||
}
|
||||
|
||||
handlers := NewHandlers(providers, nil, nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/providers", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.HandleListProviders(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Providers []ProviderInfo `json:"providers"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Providers) != 2 {
|
||||
t.Fatalf("got %d providers, want 2", len(resp.Providers))
|
||||
}
|
||||
|
||||
if resp.Providers[0].ID != "github" {
|
||||
t.Errorf("first provider ID = %q, want %q", resp.Providers[0].ID, "github")
|
||||
}
|
||||
if resp.Providers[0].DisplayName != "GitHub" {
|
||||
t.Errorf("first provider DisplayName = %q, want %q", resp.Providers[0].DisplayName, "GitHub")
|
||||
}
|
||||
if resp.Providers[1].ID != "google" {
|
||||
t.Errorf("second provider ID = %q, want %q", resp.Providers[1].ID, "google")
|
||||
}
|
||||
}
|
||||
|
||||
// --- User provisioning tests ---
|
||||
|
||||
func TestSanitizeUsername(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
provider string
|
||||
externalID string
|
||||
wantMin string // exact match, or empty for length-only check
|
||||
maxLen int
|
||||
}{
|
||||
{"normal", "testuser", "github", "123", "testuser", 0},
|
||||
{"with dash", "test-user", "github", "123", "test_user", 0},
|
||||
{"with dots", "test.user", "github", "123", "test_user", 0},
|
||||
{"with at sign", "user@email.com", "github", "123", "user_email_com", 0},
|
||||
{"too short", "ab", "github", "123", "ab_user", 0},
|
||||
{"empty", "", "github", "123", "github_123", 0},
|
||||
{"too long", "", "", "", "", 64},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := tt.username
|
||||
if tt.name == "too long" {
|
||||
b := make([]byte, 100)
|
||||
for i := range b {
|
||||
b[i] = 'a'
|
||||
}
|
||||
input = string(b)
|
||||
}
|
||||
got := sanitizeUsername(input, tt.provider, tt.externalID)
|
||||
if tt.maxLen > 0 {
|
||||
if len(got) > tt.maxLen {
|
||||
t.Errorf("sanitizeUsername too long: len=%d, want <= %d", len(got), tt.maxLen)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got != tt.wantMin {
|
||||
t.Errorf("sanitizeUsername(%q, %q, %q) = %q, want %q", tt.username, tt.provider, tt.externalID, got, tt.wantMin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindOrCreateUser_NewUser(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
idStore := NewUserIdentityStore(db)
|
||||
userStore := newTestUserStore(db, t)
|
||||
|
||||
handlers := &Handlers{
|
||||
idStore: idStore,
|
||||
userStore: userStore,
|
||||
logger: slog.Default(),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ext := &ExternalUser{
|
||||
ProviderID: "github",
|
||||
ExternalID: "gh-newuser-001",
|
||||
Email: "newuser@example.com",
|
||||
Name: "New User",
|
||||
Username: "newghuser",
|
||||
RawClaims: map[string]any{"login": "newghuser"},
|
||||
}
|
||||
|
||||
user, err := handlers.findOrCreateUser(ctx, ext)
|
||||
if err != nil {
|
||||
t.Fatalf("findOrCreateUser: %v", err)
|
||||
}
|
||||
|
||||
if user.Username != "newghuser" {
|
||||
t.Errorf("Username = %q, want %q", user.Username, "newghuser")
|
||||
}
|
||||
if user.DisplayName != "New User" {
|
||||
t.Errorf("DisplayName = %q, want %q", user.DisplayName, "New User")
|
||||
}
|
||||
|
||||
// Identity should be linked
|
||||
foundID, err := idStore.FindByProvider(ctx, "github", "gh-newuser-001")
|
||||
if err != nil {
|
||||
t.Fatalf("identity not linked: %v", err)
|
||||
}
|
||||
if foundID != user.ID {
|
||||
t.Errorf("linked user ID = %d, want %d", foundID, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindOrCreateUser_ExistingIdentity(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
idStore := NewUserIdentityStore(db)
|
||||
userStore := newTestUserStore(db, t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a user first
|
||||
user, err := userStore.CreateUser(ctx, "existinguser", "password1234", "Existing User")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
// Link identity manually
|
||||
err = idStore.Create(ctx, user.ID, "github", "gh-existing-001", "existing@example.com", "Existing", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create identity: %v", err)
|
||||
}
|
||||
|
||||
handlers := &Handlers{
|
||||
idStore: idStore,
|
||||
userStore: userStore,
|
||||
logger: slog.Default(),
|
||||
}
|
||||
|
||||
ext := &ExternalUser{
|
||||
ProviderID: "github",
|
||||
ExternalID: "gh-existing-001",
|
||||
Email: "existing@example.com",
|
||||
Name: "Existing User",
|
||||
Username: "existinguser",
|
||||
}
|
||||
|
||||
found, err := handlers.findOrCreateUser(ctx, ext)
|
||||
if err != nil {
|
||||
t.Fatalf("findOrCreateUser: %v", err)
|
||||
}
|
||||
if found.ID != user.ID {
|
||||
t.Errorf("found user ID = %d, want %d", found.ID, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mock helpers ---
|
||||
|
||||
type mockProvider struct {
|
||||
id string
|
||||
providerType string
|
||||
name string
|
||||
}
|
||||
|
||||
func (m *mockProvider) ID() string { return m.id }
|
||||
func (m *mockProvider) Type() string { return m.providerType }
|
||||
func (m *mockProvider) DisplayName() string { return m.name }
|
||||
func (m *mockProvider) AuthCodeURL(state string) string {
|
||||
return "https://mock.idp.example.com/authorize?state=" + state
|
||||
}
|
||||
func (m *mockProvider) Exchange(ctx context.Context, code string) (*ExternalUser, error) {
|
||||
return &ExternalUser{
|
||||
ProviderID: m.id,
|
||||
ExternalID: "mock-ext-id",
|
||||
Email: "mock@example.com",
|
||||
Name: "Mock User",
|
||||
Username: "mockuser",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OIDCProvider implements OpenID Connect authentication.
|
||||
// Works with any OIDC-compliant provider (Google, Azure AD, etc.).
|
||||
type OIDCProvider struct {
|
||||
id string
|
||||
displayName string
|
||||
issuerURL string
|
||||
config *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
allowedDomains []string // Empty means allow all domains
|
||||
groupMapping map[string]string
|
||||
}
|
||||
|
||||
// OIDCConfig holds configuration for an OIDC provider.
|
||||
type OIDCConfig struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Scopes []string
|
||||
AllowedDomains []string
|
||||
GroupMapping map[string]string
|
||||
}
|
||||
|
||||
// NewOIDCProvider creates a new OIDC provider using discovery.
|
||||
func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (*OIDCProvider, error) {
|
||||
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: discover %s: %w", cfg.IssuerURL, err)
|
||||
}
|
||||
|
||||
scopes := cfg.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{oidc.ScopeOpenID, "email", "profile"}
|
||||
}
|
||||
|
||||
oauthConfig := &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
})
|
||||
|
||||
return &OIDCProvider{
|
||||
id: cfg.ID,
|
||||
displayName: cfg.DisplayName,
|
||||
issuerURL: cfg.IssuerURL,
|
||||
config: oauthConfig,
|
||||
verifier: verifier,
|
||||
allowedDomains: cfg.AllowedDomains,
|
||||
groupMapping: cfg.GroupMapping,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) ID() string { return p.id }
|
||||
func (p *OIDCProvider) Type() string { return "oidc" }
|
||||
func (p *OIDCProvider) DisplayName() string { return p.displayName }
|
||||
|
||||
func (p *OIDCProvider) AuthCodeURL(state string) string {
|
||||
return p.config.AuthCodeURL(state)
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) Exchange(ctx context.Context, code string) (*ExternalUser, error) {
|
||||
token, err := p.config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: exchange code: %w", err)
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("oidc: no id_token in response")
|
||||
}
|
||||
|
||||
idToken, err := p.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: verify id_token: %w", err)
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"preferred_username"`
|
||||
HD string `json:"hd"` // Google hosted domain
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("oidc: parse claims: %w", err)
|
||||
}
|
||||
|
||||
// Extract raw claims for storage
|
||||
var rawClaims map[string]any
|
||||
if err := idToken.Claims(&rawClaims); err != nil {
|
||||
rawClaims = map[string]any{"sub": claims.Sub}
|
||||
}
|
||||
|
||||
// Validate domain restriction
|
||||
if len(p.allowedDomains) > 0 {
|
||||
if err := p.validateDomain(claims.Email, claims.HD); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Map groups through group mapping
|
||||
var mappedGroups []string
|
||||
if len(p.groupMapping) > 0 {
|
||||
for _, group := range claims.Groups {
|
||||
if mapped, ok := p.groupMapping[group]; ok {
|
||||
mappedGroups = append(mappedGroups, mapped)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mappedGroups = claims.Groups
|
||||
}
|
||||
|
||||
// Derive username from email if preferred_username is empty
|
||||
username := claims.Username
|
||||
if username == "" && claims.Email != "" {
|
||||
parts := strings.SplitN(claims.Email, "@", 2)
|
||||
username = parts[0]
|
||||
}
|
||||
|
||||
return &ExternalUser{
|
||||
ProviderID: p.id,
|
||||
ExternalID: claims.Sub,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
Username: username,
|
||||
Groups: mappedGroups,
|
||||
RawClaims: rawClaims,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateDomain checks that the user's email domain is in the allowed list.
|
||||
func (p *OIDCProvider) validateDomain(email, hostedDomain string) error {
|
||||
// Prefer hd (hosted domain) claim when available (Google Workspace)
|
||||
domain := hostedDomain
|
||||
if domain == "" && email != "" {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) == 2 {
|
||||
domain = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
if domain == "" {
|
||||
return fmt.Errorf("oidc: no domain found in claims, required domains: %v", p.allowedDomains)
|
||||
}
|
||||
|
||||
for _, allowed := range p.allowedDomains {
|
||||
if strings.EqualFold(domain, allowed) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("oidc: domain %q not in allowed list %v", domain, p.allowedDomains)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package idp provides enterprise identity provider integrations for SynapBus.
|
||||
// Supported providers: GitHub (OAuth), Google (OIDC), Azure AD (OIDC).
|
||||
package idp
|
||||
|
||||
import "context"
|
||||
|
||||
// Provider is the interface that all identity providers must implement.
|
||||
type Provider interface {
|
||||
// ID returns the unique identifier for this provider (e.g., "github", "google", "azuread").
|
||||
ID() string
|
||||
// Type returns the provider type ("oauth" or "oidc").
|
||||
Type() string
|
||||
// DisplayName returns the human-readable name (e.g., "GitHub", "Google").
|
||||
DisplayName() string
|
||||
// AuthCodeURL generates the authorization URL with the given state parameter.
|
||||
AuthCodeURL(state string) string
|
||||
// Exchange trades an authorization code for user information.
|
||||
Exchange(ctx context.Context, code string) (*ExternalUser, error)
|
||||
}
|
||||
|
||||
// ExternalUser represents user information obtained from an external identity provider.
|
||||
type ExternalUser struct {
|
||||
ProviderID string
|
||||
ExternalID string
|
||||
Email string
|
||||
Name string
|
||||
Username string
|
||||
Groups []string
|
||||
RawClaims map[string]any
|
||||
}
|
||||
|
||||
// ProviderInfo is a minimal representation of a provider for API responses.
|
||||
type ProviderInfo struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserIdentity represents an external identity linked to a local user.
|
||||
type UserIdentity struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Provider string `json:"provider"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
RawClaims string `json:"raw_claims"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserIdentityStore manages the user_identities table.
|
||||
type UserIdentityStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewUserIdentityStore creates a new identity store backed by SQLite.
|
||||
func NewUserIdentityStore(db *sql.DB) *UserIdentityStore {
|
||||
return &UserIdentityStore{db: db}
|
||||
}
|
||||
|
||||
// FindByProvider looks up a user ID by provider name and external ID.
|
||||
// Returns sql.ErrNoRows if not found.
|
||||
func (s *UserIdentityStore) FindByProvider(ctx context.Context, provider, externalID string) (int64, error) {
|
||||
var userID int64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT user_id FROM user_identities WHERE provider = ? AND external_id = ?`,
|
||||
provider, externalID,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// Create links an external identity to a local user.
|
||||
func (s *UserIdentityStore) Create(ctx context.Context, userID int64, provider, externalID, email, displayName string, rawClaims map[string]any) error {
|
||||
claimsJSON, err := json.Marshal(rawClaims)
|
||||
if err != nil {
|
||||
claimsJSON = []byte("{}")
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO user_identities (user_id, provider, external_id, email, display_name, raw_claims, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
userID, provider, externalID, email, displayName, string(claimsJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUser returns all external identities linked to a user.
|
||||
func (s *UserIdentityStore) ListByUser(ctx context.Context, userID int64) ([]UserIdentity, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, user_id, provider, external_id, email, display_name, raw_claims, created_at, updated_at
|
||||
FROM user_identities WHERE user_id = ? ORDER BY created_at`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list identities: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var identities []UserIdentity
|
||||
for rows.Next() {
|
||||
var identity UserIdentity
|
||||
var email, displayName sql.NullString
|
||||
if err := rows.Scan(
|
||||
&identity.ID, &identity.UserID, &identity.Provider,
|
||||
&identity.ExternalID, &email, &displayName,
|
||||
&identity.RawClaims, &identity.CreatedAt, &identity.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan identity: %w", err)
|
||||
}
|
||||
identity.Email = email.String
|
||||
identity.DisplayName = displayName.String
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
if identities == nil {
|
||||
identities = []UserIdentity{}
|
||||
}
|
||||
return identities, rows.Err()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
@@ -107,8 +108,11 @@ func RequireBearer(provider fosite.OAuth2Provider, userStore UserStore) func(htt
|
||||
token := parts[1]
|
||||
_ = token
|
||||
|
||||
// Use fosite introspection
|
||||
_, ar, err := provider.IntrospectToken(r.Context(), parts[1], fosite.AccessToken, new(fositeSession))
|
||||
// Use fosite introspection — decouple from HTTP request context so
|
||||
// token validation completes even if the client disconnects.
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
|
||||
_, ar, err := provider.IntrospectToken(dbCtx, parts[1], fosite.AccessToken, new(fositeSession))
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Debug("bearer token validation failed", "error", err)
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
|
||||
@@ -18,10 +18,12 @@ type UserStore interface {
|
||||
CreateUser(ctx context.Context, username, password, displayName string) (*User, error)
|
||||
GetUserByID(ctx context.Context, id int64) (*User, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (*User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||
UpdatePassword(ctx context.Context, userID int64, newPassword string) error
|
||||
ListUsers(ctx context.Context) ([]*User, error)
|
||||
CountUsers(ctx context.Context) (int, error)
|
||||
VerifyPassword(ctx context.Context, username, password string) (*User, error)
|
||||
UpdateDisplayName(ctx context.Context, userID int64, displayName string) error
|
||||
}
|
||||
|
||||
// SQLiteUserStore implements UserStore using SQLite.
|
||||
@@ -115,6 +117,36 @@ func (s *SQLiteUserStore) GetUserByID(ctx context.Context, id int64) (*User, err
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by their email address.
|
||||
// Returns ErrUserNotFound if no user has the given email or if email is empty.
|
||||
func (s *SQLiteUserStore) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
if email == "" {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
user := &User{}
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, display_name, role, created_at, updated_at
|
||||
FROM users WHERE email = ?`, email,
|
||||
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.DisplayName,
|
||||
&user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("query user by email: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// SetEmail updates a user's email address.
|
||||
func (s *SQLiteUserStore) SetEmail(ctx context.Context, userID int64, email string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
email, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves a user by their username.
|
||||
func (s *SQLiteUserStore) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
user := &User{}
|
||||
@@ -215,3 +247,26 @@ func (s *SQLiteUserStore) VerifyPassword(ctx context.Context, username, password
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateDisplayName changes a user's display name.
|
||||
func (s *SQLiteUserStore) UpdateDisplayName(ctx context.Context, userID int64, displayName string) error {
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if displayName == "" {
|
||||
return fmt.Errorf("display name cannot be empty")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
displayName, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update display name: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("check rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -465,19 +465,24 @@ func (s *Service) UpdateChannel(ctx context.Context, channelID int64, req Update
|
||||
// If the message body contains @mentions, mentioned members receive a
|
||||
// "mention":true flag in their inbox notification metadata, and the channel
|
||||
// message metadata includes "mentioned_agents".
|
||||
func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAgent, body string, priority int, metadata string) ([]*messaging.Message, error) {
|
||||
func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAgent, body string, priority int, metadata string, replyTo *int64) ([]*messaging.Message, error) {
|
||||
ch, err := s.store.GetChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify sender is a member
|
||||
// Verify sender is a member; auto-join public channels on first send.
|
||||
isMember, err := s.store.IsMember(ctx, channelID, fromAgent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check membership: %w", err)
|
||||
}
|
||||
if !isMember {
|
||||
return nil, ErrNotChannelMember
|
||||
if ch.IsPrivate {
|
||||
return nil, ErrNotChannelMember
|
||||
}
|
||||
if err := s.JoinChannel(ctx, channelID, fromAgent); err != nil {
|
||||
return nil, fmt.Errorf("auto-join public channel: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get members for mentions and inbox notifications
|
||||
@@ -521,15 +526,16 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
Priority: priority,
|
||||
Metadata: string(channelMetaBytes),
|
||||
ChannelID: &channelID,
|
||||
ReplyTo: replyTo,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create channel message: %w", err)
|
||||
}
|
||||
|
||||
// 2. Deliver inbox notifications to other members.
|
||||
// 2. Deliver inbox notifications only to @mentioned members.
|
||||
recipientCount := 0
|
||||
for _, m := range members {
|
||||
if m.AgentName == fromAgent {
|
||||
if m.AgentName == fromAgent || !mentionedMembers[m.AgentName] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -537,12 +543,7 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
"channel_id": channelID,
|
||||
"channel_name": ch.Name,
|
||||
"channel_message_id": channelMsg.ID,
|
||||
}
|
||||
if len(mentionedAgentsList) > 0 {
|
||||
inboxMetaObj["mentioned_agents"] = mentionedAgentsList
|
||||
}
|
||||
if mentionedMembers[m.AgentName] {
|
||||
inboxMetaObj["mention"] = true
|
||||
"mention": true,
|
||||
}
|
||||
inboxMetaBytes, _ := json.Marshal(inboxMetaObj)
|
||||
|
||||
@@ -552,7 +553,7 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
Metadata: string(inboxMetaBytes),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("failed to send channel notification",
|
||||
s.logger.Error("failed to send mention notification",
|
||||
"channel_id", channelID,
|
||||
"from", fromAgent,
|
||||
"to", m.AgentName,
|
||||
|
||||
@@ -533,7 +533,7 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
ch, _ := svc.CreateChannel(ctx, CreateChannelRequest{Name: "alerts", Type: TypeStandard, CreatedBy: "agent-a"})
|
||||
|
||||
t.Run("broadcast creates channel message", func(t *testing.T) {
|
||||
msgs, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hello", 5, "")
|
||||
msgs, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hello", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -565,30 +565,25 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("broadcast delivers inbox notifications", func(t *testing.T) {
|
||||
t.Run("broadcast without mentions sends no DMs", func(t *testing.T) {
|
||||
svc.JoinChannel(ctx, ch.ID, "agent-b")
|
||||
svc.JoinChannel(ctx, ch.ID, "agent-c")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "multi-member test", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no-dm-test", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
|
||||
// agent-b should have an inbox notification
|
||||
// agent-b should NOT have an inbox notification (no @mention)
|
||||
inboxResult, _ := svc.msgService.ReadInbox(ctx, "agent-b", messaging.ReadOptions{IncludeRead: true})
|
||||
found := false
|
||||
for _, m := range inboxResult.Messages {
|
||||
if m.Body == "multi-member test" {
|
||||
found = true
|
||||
break
|
||||
if m.Body == "no-dm-test" {
|
||||
t.Error("agent-b should not receive inbox DM for non-mention broadcast")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("agent-b did not receive inbox notification")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sender does not receive own message", func(t *testing.T) {
|
||||
svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no self-message", 5, "")
|
||||
svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no self-message", 5, "", nil)
|
||||
inboxResult, _ := svc.msgService.ReadInbox(ctx, "agent-a", messaging.ReadOptions{IncludeRead: true})
|
||||
for _, m := range inboxResult.Messages {
|
||||
if m.Body == "no self-message" {
|
||||
@@ -597,12 +592,42 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-member cannot broadcast", func(t *testing.T) {
|
||||
// agent-c is a member but let's test someone who isn't
|
||||
t.Run("non-member auto-joins public channel on broadcast", func(t *testing.T) {
|
||||
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "unauthorized", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "auto-joined", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected auto-join for public channel, got %v", err)
|
||||
}
|
||||
isMember, _ := svc.IsMember(ctx, ch.ID, "outsider")
|
||||
if !isMember {
|
||||
t.Error("outsider should be a member after auto-join")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("broadcast with reply_to", func(t *testing.T) {
|
||||
msgs, _ := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "original", 5, "", nil)
|
||||
original := msgs[0]
|
||||
|
||||
replies, err := svc.BroadcastMessage(ctx, ch.ID, "agent-b", "reply to original", 5, "", &original.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage with reply_to: %v", err)
|
||||
}
|
||||
if replies[0].ReplyTo == nil || *replies[0].ReplyTo != original.ID {
|
||||
t.Errorf("reply_to = %v, want %d", replies[0].ReplyTo, original.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-member cannot broadcast to private channel", func(t *testing.T) {
|
||||
privCh, err := svc.CreateChannel(ctx, CreateChannelRequest{
|
||||
Name: "private-test", Type: TypeStandard, IsPrivate: true, CreatedBy: "agent-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create private channel: %v", err)
|
||||
}
|
||||
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider2")
|
||||
_, err = svc.BroadcastMessage(ctx, privCh.ID, "outsider2", "unauthorized", 5, "", nil)
|
||||
if !errors.Is(err, ErrNotChannelMember) {
|
||||
t.Errorf("expected ErrNotChannelMember, got %v", err)
|
||||
t.Errorf("expected ErrNotChannelMember for private channel, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -619,7 +644,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
svc.JoinChannel(ctx, ch.ID, "agent-c")
|
||||
|
||||
t.Run("mentioned member gets mention flag in inbox", func(t *testing.T) {
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @agent-b check this", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @agent-b check this", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -642,26 +667,17 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
t.Error("agent-b did not receive inbox notification")
|
||||
}
|
||||
|
||||
// agent-c was NOT mentioned — inbox notification should NOT have mention:true
|
||||
// agent-c was NOT mentioned — should NOT receive an inbox DM at all
|
||||
inboxResult, _ = svc.msgService.ReadInbox(ctx, "agent-c", messaging.ReadOptions{IncludeRead: true})
|
||||
for _, m := range inboxResult.Messages {
|
||||
if m.Body == "hey @agent-b check this" {
|
||||
var meta map[string]any
|
||||
json.Unmarshal(m.Metadata, &meta)
|
||||
if meta["mention"] == true {
|
||||
t.Error("agent-c should NOT have mention flag")
|
||||
}
|
||||
// But should still have mentioned_agents list
|
||||
if _, ok := meta["mentioned_agents"]; !ok {
|
||||
t.Error("agent-c metadata should have mentioned_agents list")
|
||||
}
|
||||
break
|
||||
t.Error("agent-c should not receive inbox DM when not @mentioned")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("channel message metadata includes mentioned_agents", func(t *testing.T) {
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "cc @agent-b and @agent-c", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "cc @agent-b and @agent-c", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -691,7 +707,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("self-mention is excluded", func(t *testing.T) {
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "I am @agent-a and cc @agent-b", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "I am @agent-a and cc @agent-b", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -716,7 +732,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("no mentions produces no mention metadata", func(t *testing.T) {
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "just a normal message", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "just a normal message", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -736,7 +752,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
|
||||
t.Run("non-member mention is ignored", func(t *testing.T) {
|
||||
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @outsider and @agent-b", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @outsider and @agent-b", 5, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
|
||||
@@ -546,7 +546,15 @@ func (b *ServiceBridge) callSendChannelMessage(ctx context.Context, args map[str
|
||||
priority := getInt(args, "priority", 5)
|
||||
metadata := getString(args, "metadata", "")
|
||||
|
||||
messages, err := b.channelService.BroadcastMessage(ctx, channelID, b.agentName, body, priority, metadata)
|
||||
var replyTo *int64
|
||||
if v, ok := args["reply_to"]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
r := int64(f)
|
||||
replyTo = &r
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := b.channelService.BroadcastMessage(ctx, channelID, b.agentName, body, priority, metadata, replyTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
// PromptRegistrar registers MCP prompts on the server.
|
||||
type PromptRegistrar struct {
|
||||
db *sql.DB
|
||||
agentService *agents.AgentService
|
||||
channelService *channels.Service
|
||||
traceStore trace.TraceStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewPromptRegistrar creates a new prompt registrar.
|
||||
func NewPromptRegistrar(
|
||||
db *sql.DB,
|
||||
agentService *agents.AgentService,
|
||||
channelService *channels.Service,
|
||||
traceStore trace.TraceStore,
|
||||
) *PromptRegistrar {
|
||||
return &PromptRegistrar{
|
||||
db: db,
|
||||
agentService: agentService,
|
||||
channelService: channelService,
|
||||
traceStore: traceStore,
|
||||
logger: slog.Default().With("component", "mcp-prompts"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAllOnServer registers all 4 MCP prompts on an mcp-go MCPServer.
|
||||
func (p *PromptRegistrar) RegisterAllOnServer(s *server.MCPServer) {
|
||||
s.AddPrompt(p.dailyDigestPrompt(), p.handleDailyDigest)
|
||||
s.AddPrompt(p.agentHealthCheckPrompt(), p.handleAgentHealthCheck)
|
||||
s.AddPrompt(p.channelOverviewPrompt(), p.handleChannelOverview)
|
||||
s.AddPrompt(p.debugAgentPrompt(), p.handleDebugAgent)
|
||||
|
||||
p.logger.Info("MCP prompts registered", "count", 4)
|
||||
}
|
||||
|
||||
// --- Prompt Definitions ---
|
||||
|
||||
func (p *PromptRegistrar) dailyDigestPrompt() mcplib.Prompt {
|
||||
return mcplib.NewPrompt("daily-digest",
|
||||
mcplib.WithPromptDescription("Summary of SynapBus activity in the last 24 hours: message counts, active agents, busiest channels, and high-priority messages."),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *PromptRegistrar) agentHealthCheckPrompt() mcplib.Prompt {
|
||||
return mcplib.NewPrompt("agent-health-check",
|
||||
mcplib.WithPromptDescription("Status overview of all registered agents: name, type, status, last activity, and pending DM count. Flags agents needing attention."),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *PromptRegistrar) channelOverviewPrompt() mcplib.Prompt {
|
||||
return mcplib.NewPrompt("channel-overview",
|
||||
mcplib.WithPromptDescription("Overview of all channels: name, type, member count, and recent message activity."),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *PromptRegistrar) debugAgentPrompt() mcplib.Prompt {
|
||||
return mcplib.NewPrompt("debug-agent",
|
||||
mcplib.WithPromptDescription("Detailed debug information for a specific agent: identity, recent traces, pending messages, and errors."),
|
||||
mcplib.WithArgument("agent_name",
|
||||
mcplib.ArgumentDescription("Name of the agent to debug"),
|
||||
mcplib.RequiredArgument(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Prompt Handlers ---
|
||||
|
||||
func (p *PromptRegistrar) handleDailyDigest(ctx context.Context, req mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) {
|
||||
since := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
var md strings.Builder
|
||||
md.WriteString("# SynapBus Daily Digest\n\n")
|
||||
md.WriteString(fmt.Sprintf("*Period: %s to %s*\n\n", since.Format(time.RFC3339), time.Now().Format(time.RFC3339)))
|
||||
|
||||
// Total messages sent in last 24h
|
||||
var totalMessages int
|
||||
err := p.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE created_at >= ?`, since,
|
||||
).Scan(&totalMessages)
|
||||
if err != nil {
|
||||
md.WriteString(fmt.Sprintf("Error querying messages: %s\n\n", err))
|
||||
} else {
|
||||
md.WriteString(fmt.Sprintf("## Messages\n\n**Total messages sent**: %d\n\n", totalMessages))
|
||||
}
|
||||
|
||||
// Active agents (those who sent messages in last 24h)
|
||||
rows, err := p.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT from_agent FROM messages WHERE created_at >= ? AND from_agent != '' ORDER BY from_agent`,
|
||||
since,
|
||||
)
|
||||
if err != nil {
|
||||
md.WriteString(fmt.Sprintf("Error querying active agents: %s\n\n", err))
|
||||
} else {
|
||||
var activeAgents []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err == nil {
|
||||
activeAgents = append(activeAgents, name)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
md.WriteString("## Active Agents\n\n")
|
||||
if len(activeAgents) == 0 {
|
||||
md.WriteString("No agents were active in the last 24 hours.\n\n")
|
||||
} else {
|
||||
md.WriteString(fmt.Sprintf("**%d agents** sent messages: %s\n\n", len(activeAgents), strings.Join(activeAgents, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// Top 3 busiest channels
|
||||
chanRows, err := p.db.QueryContext(ctx,
|
||||
`SELECT c.name, COUNT(m.id) as msg_count
|
||||
FROM messages m
|
||||
JOIN channels c ON m.channel_id = c.id
|
||||
WHERE m.created_at >= ? AND m.channel_id IS NOT NULL
|
||||
GROUP BY c.name
|
||||
ORDER BY msg_count DESC
|
||||
LIMIT 3`,
|
||||
since,
|
||||
)
|
||||
if err != nil {
|
||||
md.WriteString(fmt.Sprintf("Error querying channels: %s\n\n", err))
|
||||
} else {
|
||||
md.WriteString("## Top Channels\n\n")
|
||||
md.WriteString("| Channel | Messages |\n")
|
||||
md.WriteString("|---------|----------|\n")
|
||||
found := false
|
||||
for chanRows.Next() {
|
||||
var chName string
|
||||
var msgCount int
|
||||
if err := chanRows.Scan(&chName, &msgCount); err == nil {
|
||||
md.WriteString(fmt.Sprintf("| #%s | %d |\n", chName, msgCount))
|
||||
found = true
|
||||
}
|
||||
}
|
||||
chanRows.Close()
|
||||
if !found {
|
||||
md.WriteString("| (no channel activity) | - |\n")
|
||||
}
|
||||
md.WriteString("\n")
|
||||
}
|
||||
|
||||
// High-priority messages (priority >= 7)
|
||||
highRows, err := p.db.QueryContext(ctx,
|
||||
`SELECT id, from_agent, COALESCE(to_agent, ''), body, priority, created_at
|
||||
FROM messages
|
||||
WHERE created_at >= ? AND priority >= 7
|
||||
ORDER BY priority DESC, created_at DESC
|
||||
LIMIT 20`,
|
||||
since,
|
||||
)
|
||||
if err != nil {
|
||||
md.WriteString(fmt.Sprintf("Error querying high-priority messages: %s\n\n", err))
|
||||
} else {
|
||||
md.WriteString("## High-Priority Messages (>= 7)\n\n")
|
||||
var highPriorityFound bool
|
||||
for highRows.Next() {
|
||||
var id int64
|
||||
var from, to, body string
|
||||
var priority int
|
||||
var createdAt time.Time
|
||||
if err := highRows.Scan(&id, &from, &to, &body, &priority, &createdAt); err == nil {
|
||||
if !highPriorityFound {
|
||||
md.WriteString("| ID | From | To | Priority | Time | Body (truncated) |\n")
|
||||
md.WriteString("|----|------|----|----------|------|------------------|\n")
|
||||
highPriorityFound = true
|
||||
}
|
||||
if len(body) > 80 {
|
||||
body = body[:80] + "..."
|
||||
}
|
||||
// Escape pipe characters in body for markdown table
|
||||
body = strings.ReplaceAll(body, "|", "\\|")
|
||||
body = strings.ReplaceAll(body, "\n", " ")
|
||||
target := to
|
||||
if target == "" {
|
||||
target = "(channel)"
|
||||
}
|
||||
md.WriteString(fmt.Sprintf("| %d | %s | %s | %d | %s | %s |\n",
|
||||
id, from, target, priority, createdAt.Format("15:04"), body))
|
||||
}
|
||||
}
|
||||
highRows.Close()
|
||||
if !highPriorityFound {
|
||||
md.WriteString("No high-priority messages in the last 24 hours.\n")
|
||||
}
|
||||
md.WriteString("\n")
|
||||
}
|
||||
|
||||
return &mcplib.GetPromptResult{
|
||||
Description: "SynapBus activity summary for the last 24 hours",
|
||||
Messages: []mcplib.PromptMessage{
|
||||
{
|
||||
Role: mcplib.RoleUser,
|
||||
Content: mcplib.NewTextContent(md.String()),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PromptRegistrar) handleAgentHealthCheck(ctx context.Context, req mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) {
|
||||
var md strings.Builder
|
||||
md.WriteString("# Agent Health Check\n\n")
|
||||
|
||||
// Get all active agents
|
||||
agentList, err := p.agentService.DiscoverAgents(ctx, "")
|
||||
if err != nil {
|
||||
return &mcplib.GetPromptResult{
|
||||
Description: "Agent health check",
|
||||
Messages: []mcplib.PromptMessage{
|
||||
{
|
||||
Role: mcplib.RoleUser,
|
||||
Content: mcplib.NewTextContent(fmt.Sprintf("Error listing agents: %s", err)),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
md.WriteString("| Agent | Type | Status | Last Activity | Pending DMs | Notes |\n")
|
||||
md.WriteString("|-------|------|--------|---------------|-------------|-------|\n")
|
||||
|
||||
for _, agent := range agentList {
|
||||
if agent.Name == "system" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get last activity from traces
|
||||
lastActivity := "unknown"
|
||||
if p.traceStore != nil {
|
||||
traces, err := p.traceStore.GetTraces(ctx, agent.Name, 1)
|
||||
if err == nil && len(traces) > 0 {
|
||||
lastActivity = traces[0].CreatedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
|
||||
// Get pending DM count
|
||||
var pendingDMs int
|
||||
err := p.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE to_agent = ? AND status = 'pending'`,
|
||||
agent.Name,
|
||||
).Scan(&pendingDMs)
|
||||
if err != nil {
|
||||
pendingDMs = -1
|
||||
}
|
||||
|
||||
notes := ""
|
||||
if pendingDMs > 10 {
|
||||
notes = "**NEEDS ATTENTION**"
|
||||
}
|
||||
|
||||
pendingStr := fmt.Sprintf("%d", pendingDMs)
|
||||
if pendingDMs < 0 {
|
||||
pendingStr = "error"
|
||||
}
|
||||
|
||||
md.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s |\n",
|
||||
agent.Name, agent.Type, agent.Status, lastActivity, pendingStr, notes))
|
||||
}
|
||||
|
||||
md.WriteString(fmt.Sprintf("\n*Total agents: %d*\n", len(agentList)-1)) // exclude system
|
||||
|
||||
return &mcplib.GetPromptResult{
|
||||
Description: "Status of all registered agents",
|
||||
Messages: []mcplib.PromptMessage{
|
||||
{
|
||||
Role: mcplib.RoleUser,
|
||||
Content: mcplib.NewTextContent(md.String()),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PromptRegistrar) handleChannelOverview(ctx context.Context, req mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) {
|
||||
since := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
var md strings.Builder
|
||||
md.WriteString("# Channel Overview\n\n")
|
||||
|
||||
// Query all channels with member counts and recent message counts
|
||||
rows, err := p.db.QueryContext(ctx,
|
||||
`SELECT c.id, c.name, c.type, c.is_private,
|
||||
(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id) as member_count,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= ?) as msg_count_24h
|
||||
FROM channels c
|
||||
ORDER BY c.name`,
|
||||
since,
|
||||
)
|
||||
if err != nil {
|
||||
return &mcplib.GetPromptResult{
|
||||
Description: "Channel overview",
|
||||
Messages: []mcplib.PromptMessage{
|
||||
{
|
||||
Role: mcplib.RoleUser,
|
||||
Content: mcplib.NewTextContent(fmt.Sprintf("Error querying channels: %s", err)),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
md.WriteString("| Channel | Type | Private | Members | Messages (24h) |\n")
|
||||
md.WriteString("|---------|------|---------|---------|----------------|\n")
|
||||
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name, chType string
|
||||
var isPrivate int
|
||||
var memberCount, msgCount int
|
||||
|
||||
if err := rows.Scan(&id, &name, &chType, &isPrivate, &memberCount, &msgCount); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
privateStr := "no"
|
||||
if isPrivate != 0 {
|
||||
privateStr = "yes"
|
||||
}
|
||||
|
||||
md.WriteString(fmt.Sprintf("| #%s | %s | %s | %d | %d |\n",
|
||||
name, chType, privateStr, memberCount, msgCount))
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
md.WriteString("| (no channels) | - | - | - | - |\n")
|
||||
}
|
||||
|
||||
md.WriteString(fmt.Sprintf("\n*Total channels: %d*\n", count))
|
||||
|
||||
return &mcplib.GetPromptResult{
|
||||
Description: "Overview of all channels",
|
||||
Messages: []mcplib.PromptMessage{
|
||||
{
|
||||
Role: mcplib.RoleUser,
|
||||
Content: mcplib.NewTextContent(md.String()),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PromptRegistrar) handleDebugAgent(ctx context.Context, req mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) {
|
||||
agentName := req.Params.Arguments["agent_name"]
|
||||
if agentName == "" {
|
||||
return &mcplib.GetPromptResult{
|
||||
Description: "Debug agent",
|
||||
Messages: []mcplib.PromptMessage{
|
||||
{
|
||||
Role: mcplib.RoleUser,
|
||||
Content: mcplib.NewTextContent("Error: agent_name argument is required"),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var md strings.Builder
|
||||
md.WriteString(fmt.Sprintf("# Debug: Agent `%s`\n\n", agentName))
|
||||
|
||||
// Agent details
|
||||
agent, err := p.agentService.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return &mcplib.GetPromptResult{
|
||||
Description: fmt.Sprintf("Debug info for agent %s", agentName),
|
||||
Messages: []mcplib.PromptMessage{
|
||||
{
|
||||
Role: mcplib.RoleUser,
|
||||
Content: mcplib.NewTextContent(fmt.Sprintf("Error: agent not found: %s", err)),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Resolve owner name
|
||||
ownerName := "unknown"
|
||||
if p.db != nil {
|
||||
var username sql.NullString
|
||||
_ = p.db.QueryRowContext(ctx,
|
||||
`SELECT username FROM users WHERE id = ?`, agent.OwnerID,
|
||||
).Scan(&username)
|
||||
if username.Valid {
|
||||
ownerName = username.String
|
||||
}
|
||||
}
|
||||
|
||||
md.WriteString("## Identity\n\n")
|
||||
md.WriteString(fmt.Sprintf("| Field | Value |\n"))
|
||||
md.WriteString(fmt.Sprintf("|-------|-------|\n"))
|
||||
md.WriteString(fmt.Sprintf("| Name | %s |\n", agent.Name))
|
||||
md.WriteString(fmt.Sprintf("| Display Name | %s |\n", agent.DisplayName))
|
||||
md.WriteString(fmt.Sprintf("| Type | %s |\n", agent.Type))
|
||||
md.WriteString(fmt.Sprintf("| Status | %s |\n", agent.Status))
|
||||
md.WriteString(fmt.Sprintf("| Owner | %s (ID: %d) |\n", ownerName, agent.OwnerID))
|
||||
md.WriteString(fmt.Sprintf("| Capabilities | `%s` |\n", string(agent.Capabilities)))
|
||||
md.WriteString(fmt.Sprintf("| Created | %s |\n", agent.CreatedAt.Format(time.RFC3339)))
|
||||
md.WriteString("\n")
|
||||
|
||||
// Pending messages count
|
||||
var pendingCount int
|
||||
err = p.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE to_agent = ? AND status = 'pending'`,
|
||||
agentName,
|
||||
).Scan(&pendingCount)
|
||||
if err != nil {
|
||||
md.WriteString(fmt.Sprintf("Error querying pending messages: %s\n\n", err))
|
||||
} else {
|
||||
md.WriteString(fmt.Sprintf("## Pending Messages\n\n**Count**: %d\n\n", pendingCount))
|
||||
}
|
||||
|
||||
// Recent traces (last 10)
|
||||
if p.traceStore != nil {
|
||||
traces, err := p.traceStore.GetTraces(ctx, agentName, 10)
|
||||
if err != nil {
|
||||
md.WriteString(fmt.Sprintf("Error querying traces: %s\n\n", err))
|
||||
} else {
|
||||
md.WriteString("## Recent Traces (last 10)\n\n")
|
||||
if len(traces) == 0 {
|
||||
md.WriteString("No traces found.\n\n")
|
||||
} else {
|
||||
md.WriteString("| Time | Action | Details | Error |\n")
|
||||
md.WriteString("|------|--------|---------|-------|\n")
|
||||
for _, t := range traces {
|
||||
details := t.Details
|
||||
if len(details) > 80 {
|
||||
details = details[:80] + "..."
|
||||
}
|
||||
details = strings.ReplaceAll(details, "|", "\\|")
|
||||
details = strings.ReplaceAll(details, "\n", " ")
|
||||
|
||||
errStr := ""
|
||||
if t.Error.Valid {
|
||||
errStr = t.Error.String
|
||||
if len(errStr) > 60 {
|
||||
errStr = errStr[:60] + "..."
|
||||
}
|
||||
errStr = strings.ReplaceAll(errStr, "|", "\\|")
|
||||
}
|
||||
|
||||
md.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n",
|
||||
t.CreatedAt.Format("15:04:05"), t.Action, details, errStr))
|
||||
}
|
||||
md.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Recent errors from traces
|
||||
errorTraces, err := p.traceStore.GetTraces(ctx, agentName, 50)
|
||||
if err == nil {
|
||||
md.WriteString("## Recent Errors\n\n")
|
||||
errorFound := false
|
||||
for _, t := range errorTraces {
|
||||
if t.Error.Valid && t.Error.String != "" {
|
||||
if !errorFound {
|
||||
md.WriteString("| Time | Action | Error |\n")
|
||||
md.WriteString("|------|--------|-------|\n")
|
||||
errorFound = true
|
||||
}
|
||||
errStr := t.Error.String
|
||||
if len(errStr) > 100 {
|
||||
errStr = errStr[:100] + "..."
|
||||
}
|
||||
errStr = strings.ReplaceAll(errStr, "|", "\\|")
|
||||
errStr = strings.ReplaceAll(errStr, "\n", " ")
|
||||
md.WriteString(fmt.Sprintf("| %s | %s | %s |\n",
|
||||
t.CreatedAt.Format("2006-01-02 15:04:05"), t.Action, errStr))
|
||||
}
|
||||
}
|
||||
if !errorFound {
|
||||
md.WriteString("No recent errors found.\n")
|
||||
}
|
||||
md.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
md.WriteString("## Traces\n\n*Trace store not available*\n\n")
|
||||
}
|
||||
|
||||
return &mcplib.GetPromptResult{
|
||||
Description: fmt.Sprintf("Debug information for agent %s", agentName),
|
||||
Messages: []mcplib.PromptMessage{
|
||||
{
|
||||
Role: mcplib.RoleUser,
|
||||
Content: mcplib.NewTextContent(md.String()),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
func newTestPromptRegistrar(t *testing.T) (*PromptRegistrar, *messaging.MessagingService, *agents.AgentService, *channels.Service) {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgService := messaging.NewMessagingService(msgStore, tracer)
|
||||
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
channelStore := channels.NewSQLiteChannelStore(db)
|
||||
channelService := channels.NewService(channelStore, msgService, tracer)
|
||||
|
||||
traceStore := trace.NewSQLiteTraceStore(db)
|
||||
|
||||
registrar := NewPromptRegistrar(db, agentService, channelService, traceStore)
|
||||
return registrar, msgService, agentService, channelService
|
||||
}
|
||||
|
||||
// extractPromptText extracts the text content from a GetPromptResult.
|
||||
func extractPromptText(t *testing.T, result *mcplib.GetPromptResult) string {
|
||||
t.Helper()
|
||||
if len(result.Messages) == 0 {
|
||||
t.Fatal("expected at least one prompt message")
|
||||
}
|
||||
tc, ok := result.Messages[0].Content.(mcplib.TextContent)
|
||||
if !ok {
|
||||
t.Fatalf("expected TextContent, got %T", result.Messages[0].Content)
|
||||
}
|
||||
return tc.Text
|
||||
}
|
||||
|
||||
func TestPrompt_DailyDigest(t *testing.T) {
|
||||
p, msgService, agentService, _ := newTestPromptRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Register agents
|
||||
agentService.Register(ctx, "agent-a", "Agent A", "ai", nil, 1)
|
||||
agentService.Register(ctx, "agent-b", "Agent B", "ai", nil, 1)
|
||||
|
||||
t.Run("empty system", func(t *testing.T) {
|
||||
req := mcplib.GetPromptRequest{}
|
||||
result, err := p.handleDailyDigest(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleDailyDigest: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "Daily Digest") {
|
||||
t.Error("expected 'Daily Digest' in output")
|
||||
}
|
||||
if !strings.Contains(text, "Total messages sent") {
|
||||
t.Error("expected 'Total messages sent' in output")
|
||||
}
|
||||
if result.Messages[0].Role != mcplib.RoleUser {
|
||||
t.Errorf("expected role=user, got %s", result.Messages[0].Role)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with messages", func(t *testing.T) {
|
||||
// Send some messages
|
||||
msgService.SendMessage(ctx, "agent-a", "agent-b", "hello", messaging.SendOptions{})
|
||||
msgService.SendMessage(ctx, "agent-b", "agent-a", "hi back", messaging.SendOptions{})
|
||||
msgService.SendMessage(ctx, "agent-a", "agent-b", "urgent!", messaging.SendOptions{Priority: 8})
|
||||
|
||||
req := mcplib.GetPromptRequest{}
|
||||
result, err := p.handleDailyDigest(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleDailyDigest: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "agent-a") {
|
||||
t.Error("expected 'agent-a' in active agents")
|
||||
}
|
||||
if !strings.Contains(text, "agent-b") {
|
||||
t.Error("expected 'agent-b' in active agents")
|
||||
}
|
||||
// Should have the high priority message
|
||||
if !strings.Contains(text, "High-Priority") {
|
||||
t.Error("expected 'High-Priority' section in output")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrompt_AgentHealthCheck(t *testing.T) {
|
||||
p, msgService, agentService, _ := newTestPromptRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("no agents", func(t *testing.T) {
|
||||
req := mcplib.GetPromptRequest{}
|
||||
result, err := p.handleAgentHealthCheck(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleAgentHealthCheck: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "Agent Health Check") {
|
||||
t.Error("expected 'Agent Health Check' header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with agents and pending DMs", func(t *testing.T) {
|
||||
agentService.Register(ctx, "healthy-agent", "Healthy", "ai", nil, 1)
|
||||
agentService.Register(ctx, "overloaded-agent", "Overloaded", "ai", nil, 1)
|
||||
|
||||
// Send 12 messages to overloaded-agent to trigger "NEEDS ATTENTION"
|
||||
for i := 0; i < 12; i++ {
|
||||
msgService.SendMessage(ctx, "healthy-agent", "overloaded-agent", "msg", messaging.SendOptions{})
|
||||
}
|
||||
|
||||
req := mcplib.GetPromptRequest{}
|
||||
result, err := p.handleAgentHealthCheck(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleAgentHealthCheck: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "healthy-agent") {
|
||||
t.Error("expected 'healthy-agent' in output")
|
||||
}
|
||||
if !strings.Contains(text, "overloaded-agent") {
|
||||
t.Error("expected 'overloaded-agent' in output")
|
||||
}
|
||||
if !strings.Contains(text, "NEEDS ATTENTION") {
|
||||
t.Error("expected 'NEEDS ATTENTION' flag for overloaded agent")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrompt_ChannelOverview(t *testing.T) {
|
||||
p, _, agentService, channelService := newTestPromptRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("no channels", func(t *testing.T) {
|
||||
req := mcplib.GetPromptRequest{}
|
||||
result, err := p.handleChannelOverview(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleChannelOverview: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "Channel Overview") {
|
||||
t.Error("expected 'Channel Overview' header")
|
||||
}
|
||||
if !strings.Contains(text, "Total channels: 0") {
|
||||
t.Error("expected 'Total channels: 0' with no channels")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with channels", func(t *testing.T) {
|
||||
agentService.Register(ctx, "chan-creator", "Chan Creator", "ai", nil, 1)
|
||||
|
||||
channelService.CreateChannel(ctx, channels.CreateChannelRequest{
|
||||
Name: "general",
|
||||
Type: "standard",
|
||||
CreatedBy: "chan-creator",
|
||||
})
|
||||
channelService.CreateChannel(ctx, channels.CreateChannelRequest{
|
||||
Name: "private-ops",
|
||||
Type: "standard",
|
||||
IsPrivate: true,
|
||||
CreatedBy: "chan-creator",
|
||||
})
|
||||
|
||||
req := mcplib.GetPromptRequest{}
|
||||
result, err := p.handleChannelOverview(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleChannelOverview: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "general") {
|
||||
t.Error("expected 'general' channel in output")
|
||||
}
|
||||
if !strings.Contains(text, "private-ops") {
|
||||
t.Error("expected 'private-ops' channel in output")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrompt_DebugAgent(t *testing.T) {
|
||||
p, msgService, agentService, _ := newTestPromptRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("missing agent_name", func(t *testing.T) {
|
||||
req := mcplib.GetPromptRequest{
|
||||
Params: mcplib.GetPromptParams{
|
||||
Name: "debug-agent",
|
||||
Arguments: map[string]string{},
|
||||
},
|
||||
}
|
||||
result, err := p.handleDebugAgent(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleDebugAgent: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "agent_name argument is required") {
|
||||
t.Error("expected error message about missing agent_name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent not found", func(t *testing.T) {
|
||||
req := mcplib.GetPromptRequest{
|
||||
Params: mcplib.GetPromptParams{
|
||||
Name: "debug-agent",
|
||||
Arguments: map[string]string{
|
||||
"agent_name": "nonexistent",
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := p.handleDebugAgent(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleDebugAgent: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "agent not found") {
|
||||
t.Error("expected 'agent not found' error message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent exists", func(t *testing.T) {
|
||||
agentService.Register(ctx, "debug-target", "Debug Target", "ai", nil, 1)
|
||||
|
||||
// Send some messages to create pending DMs
|
||||
agentService.Register(ctx, "sender-for-debug", "Sender", "ai", nil, 1)
|
||||
msgService.SendMessage(ctx, "sender-for-debug", "debug-target", "test message", messaging.SendOptions{})
|
||||
|
||||
// Wait briefly for traces to flush
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
req := mcplib.GetPromptRequest{
|
||||
Params: mcplib.GetPromptParams{
|
||||
Name: "debug-agent",
|
||||
Arguments: map[string]string{
|
||||
"agent_name": "debug-target",
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := p.handleDebugAgent(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleDebugAgent: %v", err)
|
||||
}
|
||||
|
||||
text := extractPromptText(t, result)
|
||||
if !strings.Contains(text, "debug-target") {
|
||||
t.Error("expected 'debug-target' in output")
|
||||
}
|
||||
if !strings.Contains(text, "Identity") {
|
||||
t.Error("expected 'Identity' section in output")
|
||||
}
|
||||
if !strings.Contains(text, "Pending Messages") {
|
||||
t.Error("expected 'Pending Messages' section in output")
|
||||
}
|
||||
if !strings.Contains(text, "Recent Traces") {
|
||||
t.Error("expected 'Recent Traces' section in output")
|
||||
}
|
||||
if !strings.Contains(text, "Recent Errors") {
|
||||
t.Error("expected 'Recent Errors' section in output")
|
||||
}
|
||||
if !strings.Contains(text, "testowner") {
|
||||
t.Error("expected owner name 'testowner' in output")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrompt_Registration(t *testing.T) {
|
||||
p, _, _, _ := newTestPromptRegistrar(t)
|
||||
|
||||
// Verify prompt definitions
|
||||
t.Run("daily-digest prompt definition", func(t *testing.T) {
|
||||
prompt := p.dailyDigestPrompt()
|
||||
if prompt.Name != "daily-digest" {
|
||||
t.Errorf("name = %q, want daily-digest", prompt.Name)
|
||||
}
|
||||
if prompt.Description == "" {
|
||||
t.Error("expected non-empty description")
|
||||
}
|
||||
if len(prompt.Arguments) != 0 {
|
||||
t.Errorf("expected 0 arguments, got %d", len(prompt.Arguments))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-health-check prompt definition", func(t *testing.T) {
|
||||
prompt := p.agentHealthCheckPrompt()
|
||||
if prompt.Name != "agent-health-check" {
|
||||
t.Errorf("name = %q, want agent-health-check", prompt.Name)
|
||||
}
|
||||
if len(prompt.Arguments) != 0 {
|
||||
t.Errorf("expected 0 arguments, got %d", len(prompt.Arguments))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("channel-overview prompt definition", func(t *testing.T) {
|
||||
prompt := p.channelOverviewPrompt()
|
||||
if prompt.Name != "channel-overview" {
|
||||
t.Errorf("name = %q, want channel-overview", prompt.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("debug-agent prompt definition", func(t *testing.T) {
|
||||
prompt := p.debugAgentPrompt()
|
||||
if prompt.Name != "debug-agent" {
|
||||
t.Errorf("name = %q, want debug-agent", prompt.Name)
|
||||
}
|
||||
if len(prompt.Arguments) != 1 {
|
||||
t.Fatalf("expected 1 argument, got %d", len(prompt.Arguments))
|
||||
}
|
||||
arg := prompt.Arguments[0]
|
||||
if arg.Name != "agent_name" {
|
||||
t.Errorf("arg name = %q, want agent_name", arg.Name)
|
||||
}
|
||||
if !arg.Required {
|
||||
t.Error("expected agent_name to be required")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -141,6 +141,7 @@ func NewMCPServer(
|
||||
"SynapBus",
|
||||
"0.1.0",
|
||||
server.WithToolCapabilities(true),
|
||||
server.WithPromptCapabilities(true),
|
||||
server.WithHooks(hooks),
|
||||
)
|
||||
|
||||
@@ -159,6 +160,11 @@ func NewMCPServer(
|
||||
)
|
||||
hybridRegistrar.RegisterAllOnServer(mcpSrv)
|
||||
|
||||
// Register the 4 MCP prompts
|
||||
traceStore := trace.NewSQLiteTraceStore(db)
|
||||
promptRegistrar := NewPromptRegistrar(db, agentService, channelService, traceStore)
|
||||
promptRegistrar.RegisterAllOnServer(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 {
|
||||
@@ -183,7 +189,7 @@ func NewMCPServer(
|
||||
console: consolePrinter,
|
||||
}
|
||||
|
||||
logger.Info("MCP server initialized (4 hybrid tools, streamable HTTP transport)")
|
||||
logger.Info("MCP server initialized (4 hybrid tools, 4 prompts, streamable HTTP transport)")
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("send_message to channel failed: %s", err)), nil
|
||||
}
|
||||
|
||||
messages, err := h.channelService.BroadcastMessage(ctx, channelID, agentName, body, priority, metadataStr)
|
||||
messages, err := h.channelService.BroadcastMessage(ctx, channelID, agentName, body, priority, metadataStr, replyTo)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("send_message to channel failed: %s", err)), nil
|
||||
}
|
||||
|
||||
@@ -25,14 +25,18 @@ type ReadOptions struct {
|
||||
|
||||
// SearchOptions configures message search behavior.
|
||||
type SearchOptions struct {
|
||||
FromAgent string `json:"from_agent,omitempty"`
|
||||
ToAgent string `json:"to_agent,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
MinPriority int `json:"min_priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
After string `json:"after,omitempty"`
|
||||
Before string `json:"before,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
FromAgent string `json:"from_agent,omitempty"`
|
||||
ToAgent string `json:"to_agent,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
MinPriority int `json:"min_priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
After string `json:"after,omitempty"`
|
||||
Before string `json:"before,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Channels []string `json:"channels,omitempty"` // include messages in these channels
|
||||
ExcludeChannels []string `json:"exclude_channels,omitempty"` // exclude messages in these channels
|
||||
Agents []string `json:"agents,omitempty"` // include messages from/to these agents
|
||||
ExcludeAgents []string `json:"exclude_agents,omitempty"` // exclude messages from/to these agents
|
||||
}
|
||||
|
||||
@@ -12,11 +12,25 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
// EmbeddingNotifier is called when messages are created or deleted so the
|
||||
// embedding pipeline can enqueue them without a direct import dependency.
|
||||
type EmbeddingNotifier interface {
|
||||
OnMessageCreated(ctx context.Context, messageID int64, body string)
|
||||
}
|
||||
|
||||
// MessageListener is notified after every message is persisted.
|
||||
// Implementations must not block — use goroutines for slow work.
|
||||
type MessageListener interface {
|
||||
OnMessageSent(ctx context.Context, msg *Message)
|
||||
}
|
||||
|
||||
// MessagingService provides business logic for messaging operations.
|
||||
type MessagingService struct {
|
||||
store MessageStore
|
||||
tracer *trace.Tracer
|
||||
dispatcher dispatcher.EventDispatcher
|
||||
embeddings EmbeddingNotifier
|
||||
listeners []MessageListener
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -34,6 +48,16 @@ func (s *MessagingService) SetDispatcher(d dispatcher.EventDispatcher) {
|
||||
s.dispatcher = d
|
||||
}
|
||||
|
||||
// SetEmbeddingNotifier sets the embedding pipeline callback for new messages.
|
||||
func (s *MessagingService) SetEmbeddingNotifier(n EmbeddingNotifier) {
|
||||
s.embeddings = n
|
||||
}
|
||||
|
||||
// AddMessageListener registers a listener that is notified after message creation.
|
||||
func (s *MessagingService) AddMessageListener(l MessageListener) {
|
||||
s.listeners = append(s.listeners, l)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -121,6 +145,16 @@ func (s *MessagingService) SendMessage(ctx context.Context, from, to, body strin
|
||||
return nil, fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
|
||||
// Enqueue for embedding (async, best-effort)
|
||||
if s.embeddings != nil {
|
||||
s.embeddings.OnMessageCreated(ctx, msg.ID, msg.Body)
|
||||
}
|
||||
|
||||
// Notify listeners (SSE, etc.)
|
||||
for _, l := range s.listeners {
|
||||
l.OnMessageSent(ctx, msg)
|
||||
}
|
||||
|
||||
s.logger.Info("message sent",
|
||||
"from", from,
|
||||
"to", to,
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StalemateConfig holds stalemate detection settings.
|
||||
type StalemateConfig struct {
|
||||
// ProcessingTimeout is how long a message can stay in "processing" before auto-fail (default 24h).
|
||||
ProcessingTimeout time.Duration
|
||||
// ReminderAfter is how long a pending DM waits before a system reminder is sent (default 4h).
|
||||
ReminderAfter time.Duration
|
||||
// EscalateAfter is how long a pending DM waits before escalation to #approvals (default 48h).
|
||||
EscalateAfter time.Duration
|
||||
// Interval is how often the worker checks for stale messages (default 15m).
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
// DefaultStalemateConfig returns the default stalemate configuration.
|
||||
func DefaultStalemateConfig() StalemateConfig {
|
||||
return StalemateConfig{
|
||||
ProcessingTimeout: 24 * time.Hour,
|
||||
ReminderAfter: 4 * time.Hour,
|
||||
EscalateAfter: 48 * time.Hour,
|
||||
Interval: 15 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// parseDurationWithDays parses a duration string supporting "Nd" format for days
|
||||
// in addition to standard Go duration formats.
|
||||
func parseDurationWithDays(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty duration string")
|
||||
}
|
||||
|
||||
// Try "Nd" format (days)
|
||||
if strings.HasSuffix(s, "d") {
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
|
||||
if err == nil && days > 0 {
|
||||
return time.Duration(days) * 24 * time.Hour, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try standard Go duration
|
||||
return time.ParseDuration(s)
|
||||
}
|
||||
|
||||
// ParseStalemateConfig reads stalemate configuration from environment variables.
|
||||
func ParseStalemateConfig() StalemateConfig {
|
||||
cfg := DefaultStalemateConfig()
|
||||
|
||||
if v := os.Getenv("SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT"); v != "" {
|
||||
if d, err := parseDurationWithDays(v); err == nil && d > 0 {
|
||||
cfg.ProcessingTimeout = d
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("SYNAPBUS_STALEMATE_REMINDER_AFTER"); v != "" {
|
||||
if d, err := parseDurationWithDays(v); err == nil && d > 0 {
|
||||
cfg.ReminderAfter = d
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("SYNAPBUS_STALEMATE_ESCALATE_AFTER"); v != "" {
|
||||
if d, err := parseDurationWithDays(v); err == nil && d > 0 {
|
||||
cfg.EscalateAfter = d
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("SYNAPBUS_STALEMATE_INTERVAL"); v != "" {
|
||||
if d, err := parseDurationWithDays(v); err == nil && d > 0 {
|
||||
cfg.Interval = d
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ChannelLookup provides channel lookup by name without importing the channels package.
|
||||
type ChannelLookup interface {
|
||||
// GetChannelIDByName returns a channel ID by name, or 0 if not found.
|
||||
GetChannelIDByName(ctx context.Context, name string) (int64, error)
|
||||
}
|
||||
|
||||
// StalemateWorker periodically checks for and handles stale messages.
|
||||
type StalemateWorker struct {
|
||||
db *sql.DB
|
||||
msgService *MessagingService
|
||||
channelLookup ChannelLookup
|
||||
config StalemateConfig
|
||||
logger *slog.Logger
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewStalemateWorker creates a new stalemate detection worker.
|
||||
func NewStalemateWorker(db *sql.DB, msgService *MessagingService, channelLookup ChannelLookup, config StalemateConfig) *StalemateWorker {
|
||||
return &StalemateWorker{
|
||||
db: db,
|
||||
msgService: msgService,
|
||||
channelLookup: channelLookup,
|
||||
config: config,
|
||||
logger: slog.Default().With("component", "stalemate-worker"),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the background stalemate check loop.
|
||||
func (w *StalemateWorker) Start() {
|
||||
w.wg.Add(1)
|
||||
go func() {
|
||||
defer w.wg.Done()
|
||||
w.logger.Info("stalemate worker started",
|
||||
"interval", w.config.Interval.String(),
|
||||
"processing_timeout", w.config.ProcessingTimeout.String(),
|
||||
"reminder_after", w.config.ReminderAfter.String(),
|
||||
"escalate_after", w.config.EscalateAfter.String(),
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(w.config.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
w.checkStaleMessages(ctx)
|
||||
cancel()
|
||||
case <-w.done:
|
||||
w.logger.Info("stalemate worker stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop stops the stalemate worker and waits for it to finish.
|
||||
func (w *StalemateWorker) Stop() {
|
||||
close(w.done)
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
||||
// checkStaleMessages runs all stalemate checks.
|
||||
func (w *StalemateWorker) checkStaleMessages(ctx context.Context) {
|
||||
failed := w.failTimedOutProcessing(ctx)
|
||||
reminded := w.sendPendingReminders(ctx)
|
||||
escalated := w.escalatePendingMessages(ctx)
|
||||
|
||||
if failed > 0 || reminded > 0 || escalated > 0 {
|
||||
w.logger.Info("stalemate check complete",
|
||||
"auto_failed", failed,
|
||||
"reminders_sent", reminded,
|
||||
"escalations_sent", escalated,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// staleDM represents a stale direct message found by the worker.
|
||||
type staleDM struct {
|
||||
ID int64
|
||||
FromAgent string
|
||||
ToAgent string
|
||||
Body string
|
||||
ClaimedAt *time.Time
|
||||
ClaimedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// failTimedOutProcessing auto-fails DMs in "processing" status that have exceeded the timeout.
|
||||
func (w *StalemateWorker) failTimedOutProcessing(ctx context.Context) int64 {
|
||||
cutoff := time.Now().Add(-w.config.ProcessingTimeout)
|
||||
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT id, from_agent, to_agent, body, claimed_at, claimed_by
|
||||
FROM messages
|
||||
WHERE status = 'processing'
|
||||
AND to_agent IS NOT NULL
|
||||
AND to_agent != ''
|
||||
AND to_agent != 'system'
|
||||
AND claimed_at < ?`,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Error("query timed-out processing messages failed", "error", err)
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stale []staleDM
|
||||
for rows.Next() {
|
||||
var dm staleDM
|
||||
var claimedAt sql.NullTime
|
||||
var claimedBy sql.NullString
|
||||
if err := rows.Scan(&dm.ID, &dm.FromAgent, &dm.ToAgent, &dm.Body, &claimedAt, &claimedBy); err != nil {
|
||||
w.logger.Error("scan timed-out message failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if claimedAt.Valid {
|
||||
dm.ClaimedAt = &claimedAt.Time
|
||||
}
|
||||
if claimedBy.Valid {
|
||||
dm.ClaimedBy = claimedBy.String
|
||||
}
|
||||
stale = append(stale, dm)
|
||||
}
|
||||
|
||||
count := int64(0)
|
||||
for _, dm := range stale {
|
||||
metadata := map[string]any{"error": "claim timeout exceeded"}
|
||||
metaBytes, _ := json.Marshal(metadata)
|
||||
|
||||
// Update directly via DB since the store's UpdateMessageStatus requires the claiming agent
|
||||
_, err := w.db.ExecContext(ctx,
|
||||
`UPDATE messages SET status = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'processing'`,
|
||||
StatusFailed, string(metaBytes), dm.ID,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Error("auto-fail message failed",
|
||||
"message_id", dm.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
w.logger.Info("auto-failed stale processing message",
|
||||
"message_id", dm.ID,
|
||||
"from_agent", dm.FromAgent,
|
||||
"to_agent", dm.ToAgent,
|
||||
"claimed_by", dm.ClaimedBy,
|
||||
)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// sendPendingReminders sends system DM reminders for pending messages older than ReminderAfter.
|
||||
func (w *StalemateWorker) sendPendingReminders(ctx context.Context) int64 {
|
||||
cutoff := time.Now().Add(-w.config.ReminderAfter)
|
||||
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT id, from_agent, to_agent, body, created_at
|
||||
FROM messages
|
||||
WHERE status = 'pending'
|
||||
AND to_agent IS NOT NULL
|
||||
AND to_agent != ''
|
||||
AND from_agent != 'system'
|
||||
AND to_agent != 'system'
|
||||
AND created_at < ?`,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Error("query pending reminder candidates failed", "error", err)
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pendingMsg struct {
|
||||
ID int64
|
||||
FromAgent string
|
||||
ToAgent string
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var pending []pendingMsg
|
||||
for rows.Next() {
|
||||
var pm pendingMsg
|
||||
if err := rows.Scan(&pm.ID, &pm.FromAgent, &pm.ToAgent, &pm.Body, &pm.CreatedAt); err != nil {
|
||||
w.logger.Error("scan pending message failed", "error", err)
|
||||
continue
|
||||
}
|
||||
pending = append(pending, pm)
|
||||
}
|
||||
|
||||
count := int64(0)
|
||||
for _, pm := range pending {
|
||||
// Check if a reminder already exists for this message
|
||||
if w.reminderExists(ctx, pm.ID, pm.ToAgent) {
|
||||
continue
|
||||
}
|
||||
|
||||
age := formatAge(time.Since(pm.CreatedAt))
|
||||
truncBody := truncate(pm.Body, 100)
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"**Reminder**: You have a pending message from %s (%s old). Message: \"%s\". Please claim and process it.",
|
||||
pm.FromAgent, age, truncBody,
|
||||
)
|
||||
|
||||
_, err := w.msgService.SendMessage(ctx, "system", pm.ToAgent, body, SendOptions{
|
||||
Subject: fmt.Sprintf("stalemate-reminder:%d", pm.ID),
|
||||
Priority: 7,
|
||||
Metadata: fmt.Sprintf(`{"stalemate_reminder_for":%d}`, pm.ID),
|
||||
})
|
||||
if err != nil {
|
||||
w.logger.Error("send stalemate reminder failed",
|
||||
"message_id", pm.ID,
|
||||
"to_agent", pm.ToAgent,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
w.logger.Info("sent stalemate reminder",
|
||||
"message_id", pm.ID,
|
||||
"to_agent", pm.ToAgent,
|
||||
"from_agent", pm.FromAgent,
|
||||
"age", age,
|
||||
)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// escalatePendingMessages escalates pending messages older than EscalateAfter to #approvals.
|
||||
func (w *StalemateWorker) escalatePendingMessages(ctx context.Context) int64 {
|
||||
cutoff := time.Now().Add(-w.config.EscalateAfter)
|
||||
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT id, from_agent, to_agent, body, created_at
|
||||
FROM messages
|
||||
WHERE status = 'pending'
|
||||
AND to_agent IS NOT NULL
|
||||
AND to_agent != ''
|
||||
AND from_agent != 'system'
|
||||
AND to_agent != 'system'
|
||||
AND created_at < ?`,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Error("query escalation candidates failed", "error", err)
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pendingMsg struct {
|
||||
ID int64
|
||||
FromAgent string
|
||||
ToAgent string
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var pending []pendingMsg
|
||||
for rows.Next() {
|
||||
var pm pendingMsg
|
||||
if err := rows.Scan(&pm.ID, &pm.FromAgent, &pm.ToAgent, &pm.Body, &pm.CreatedAt); err != nil {
|
||||
w.logger.Error("scan escalation candidate failed", "error", err)
|
||||
continue
|
||||
}
|
||||
pending = append(pending, pm)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Look up #approvals channel
|
||||
channelID, err := w.channelLookup.GetChannelIDByName(ctx, "approvals")
|
||||
if err != nil {
|
||||
w.logger.Warn("cannot escalate: #approvals channel not found", "error", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
count := int64(0)
|
||||
for _, pm := range pending {
|
||||
// Check if already escalated
|
||||
if w.escalationExists(ctx, pm.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
age := formatAge(time.Since(pm.CreatedAt))
|
||||
truncBody := truncate(pm.Body, 100)
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"**ESCALATION**: Pending message for @%s from %s has been unprocessed for %s. Message: \"%s\". Manual intervention may be required.",
|
||||
pm.ToAgent, pm.FromAgent, age, truncBody,
|
||||
)
|
||||
|
||||
_, err := w.msgService.SendMessage(ctx, "system", "", body, SendOptions{
|
||||
Subject: fmt.Sprintf("stalemate-escalation:%d", pm.ID),
|
||||
Priority: 9,
|
||||
Metadata: fmt.Sprintf(`{"stalemate_escalation_for":%d}`, pm.ID),
|
||||
ChannelID: &channelID,
|
||||
})
|
||||
if err != nil {
|
||||
w.logger.Error("send escalation to #approvals failed",
|
||||
"message_id", pm.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
w.logger.Info("escalated stale message to #approvals",
|
||||
"message_id", pm.ID,
|
||||
"to_agent", pm.ToAgent,
|
||||
"from_agent", pm.FromAgent,
|
||||
"age", age,
|
||||
)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// reminderExists checks if a system reminder already exists for a given message ID.
|
||||
func (w *StalemateWorker) reminderExists(ctx context.Context, messageID int64, toAgent string) bool {
|
||||
var count int
|
||||
err := w.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages
|
||||
WHERE from_agent = 'system'
|
||||
AND to_agent = ?
|
||||
AND metadata LIKE ?`,
|
||||
toAgent, fmt.Sprintf(`%%"stalemate_reminder_for":%d%%`, messageID),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// escalationExists checks if an escalation already exists for a given message ID.
|
||||
func (w *StalemateWorker) escalationExists(ctx context.Context, messageID int64) bool {
|
||||
var count int
|
||||
err := w.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages
|
||||
WHERE from_agent = 'system'
|
||||
AND metadata LIKE ?`,
|
||||
fmt.Sprintf(`%%"stalemate_escalation_for":%d%%`, messageID),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// truncate truncates a string to maxLen characters, appending "..." if truncated.
|
||||
func truncate(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
// formatAge returns a human-readable age string.
|
||||
func formatAge(d time.Duration) string {
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
hours := int(d.Hours())
|
||||
if hours < 24 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
days := hours / 24
|
||||
remainingHours := hours % 24
|
||||
if remainingHours == 0 {
|
||||
if days == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
return fmt.Sprintf("%d days", days)
|
||||
}
|
||||
if days == 1 {
|
||||
return fmt.Sprintf("1 day %dh", remainingHours)
|
||||
}
|
||||
return fmt.Sprintf("%d days %dh", days, remainingHours)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
// stubChannelLookup implements ChannelLookup for tests.
|
||||
type stubChannelLookup struct {
|
||||
channelID int64
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubChannelLookup) GetChannelIDByName(ctx context.Context, name string) (int64, error) {
|
||||
if s.err != nil {
|
||||
return 0, s.err
|
||||
}
|
||||
return s.channelID, nil
|
||||
}
|
||||
|
||||
// newStalemateTestService creates a MessagingService and DB for stalemate tests.
|
||||
func newStalemateTestService(t *testing.T) (*MessagingService, *sql.DB) {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
|
||||
seedAgent(t, db, "sender")
|
||||
seedAgent(t, db, "receiver")
|
||||
seedAgent(t, db, "system")
|
||||
|
||||
store := NewSQLiteMessageStore(db)
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
svc := NewMessagingService(store, tracer)
|
||||
return svc, db
|
||||
}
|
||||
|
||||
// insertStaleMessage inserts a message with a specific created_at and claimed_at for testing.
|
||||
func insertStaleMessage(t *testing.T, db *sql.DB, from, to, body, status string, createdAt time.Time, claimedAt *time.Time, claimedBy string) int64 {
|
||||
t.Helper()
|
||||
|
||||
// Insert conversation first
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"stalemate-test", from, createdAt, createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert conversation: %v", err)
|
||||
}
|
||||
convID, _ := result.LastInsertId()
|
||||
|
||||
var claimedAtSQL interface{} = nil
|
||||
if claimedAt != nil {
|
||||
claimedAtSQL = *claimedAt
|
||||
}
|
||||
var claimedBySQL interface{} = nil
|
||||
if claimedBy != "" {
|
||||
claimedBySQL = claimedBy
|
||||
}
|
||||
|
||||
result, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, claimed_by, claimed_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 5, ?, '{}', ?, ?, ?, ?)`,
|
||||
convID, from, to, body, status, claimedBySQL, claimedAtSQL, createdAt, createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert stale message: %v", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func TestStalemateWorker_ProcessingTimeout(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a message in "processing" status with old claimed_at
|
||||
oldClaimedAt := time.Now().Add(-25 * time.Hour)
|
||||
msgID := insertStaleMessage(t, db, "sender", "receiver", "stale processing task", StatusProcessing, time.Now().Add(-26*time.Hour), &oldClaimedAt, "receiver")
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
config.ProcessingTimeout = 24 * time.Hour
|
||||
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no channel")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify message was auto-failed
|
||||
var status, metadata string
|
||||
err := db.QueryRowContext(ctx, `SELECT status, metadata FROM messages WHERE id = ?`, msgID).Scan(&status, &metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("query message: %v", err)
|
||||
}
|
||||
if status != StatusFailed {
|
||||
t.Errorf("status = %q, want %q", status, StatusFailed)
|
||||
}
|
||||
if metadata == "{}" {
|
||||
t.Error("expected metadata to contain error info")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_ProcessingTimeout_NotExpired(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a message in "processing" status with recent claimed_at (should NOT be failed)
|
||||
recentClaimedAt := time.Now().Add(-1 * time.Hour)
|
||||
msgID := insertStaleMessage(t, db, "sender", "receiver", "recent processing task", StatusProcessing, time.Now().Add(-2*time.Hour), &recentClaimedAt, "receiver")
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
config.ProcessingTimeout = 24 * time.Hour
|
||||
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no channel")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify message was NOT auto-failed
|
||||
var status string
|
||||
err := db.QueryRowContext(ctx, `SELECT status FROM messages WHERE id = ?`, msgID).Scan(&status)
|
||||
if err != nil {
|
||||
t.Fatalf("query message: %v", err)
|
||||
}
|
||||
if status != StatusProcessing {
|
||||
t.Errorf("status = %q, want %q (should not have been failed)", status, StatusProcessing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_PendingReminder(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a pending DM that is 5 hours old
|
||||
insertStaleMessage(t, db, "sender", "receiver", "please review this", StatusPending, time.Now().Add(-5*time.Hour), nil, "")
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
config.ReminderAfter = 4 * time.Hour
|
||||
config.EscalateAfter = 48 * time.Hour // won't trigger
|
||||
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no channel")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify a system reminder was sent to receiver
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND to_agent = 'receiver' AND body LIKE '%Reminder%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminder: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 reminder, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_SystemMessageSkip(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a pending DM FROM system (should be skipped)
|
||||
insertStaleMessage(t, db, "system", "receiver", "system notification", StatusPending, time.Now().Add(-5*time.Hour), nil, "")
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
config.ReminderAfter = 4 * time.Hour
|
||||
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no channel")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify NO reminder was sent (only the original system message should exist)
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%Reminder%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminder: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 reminders for system message, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_DuplicateReminderPrevention(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a pending DM that is old enough for a reminder
|
||||
insertStaleMessage(t, db, "sender", "receiver", "need your attention", StatusPending, time.Now().Add(-5*time.Hour), nil, "")
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
config.ReminderAfter = 4 * time.Hour
|
||||
config.EscalateAfter = 48 * time.Hour
|
||||
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no channel")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
// Run check twice
|
||||
worker.checkStaleMessages(ctx)
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify only ONE reminder was sent
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND to_agent = 'receiver' AND body LIKE '%Reminder%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminders: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 reminder (no duplicates), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_Escalation(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create #approvals channel
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at)
|
||||
VALUES (1, 'approvals', 'Approval queue', '', 'standard', 0, 0, 'system', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create approvals channel: %v", err)
|
||||
}
|
||||
// Add system as member
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO channel_members (channel_id, agent_name, role, joined_at)
|
||||
VALUES (1, 'system', 'owner', CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("add system to channel: %v", err)
|
||||
}
|
||||
|
||||
// Insert a pending DM that is 49 hours old (beyond escalation threshold)
|
||||
insertStaleMessage(t, db, "sender", "receiver", "urgent task ignored", StatusPending, time.Now().Add(-49*time.Hour), nil, "")
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
config.ReminderAfter = 4 * time.Hour
|
||||
config.EscalateAfter = 48 * time.Hour
|
||||
|
||||
lookup := &stubChannelLookup{channelID: 1}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify an escalation was sent to #approvals channel
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND channel_id = 1 AND body LIKE '%ESCALATION%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query escalations: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 escalation, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_DuplicateEscalationPrevention(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create #approvals channel
|
||||
db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at)
|
||||
VALUES (1, 'approvals', 'Approval queue', '', 'standard', 0, 0, 'system', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
db.Exec(
|
||||
`INSERT INTO channel_members (channel_id, agent_name, role, joined_at)
|
||||
VALUES (1, 'system', 'owner', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a pending DM that is 49 hours old
|
||||
insertStaleMessage(t, db, "sender", "receiver", "urgent task", StatusPending, time.Now().Add(-49*time.Hour), nil, "")
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
config.ReminderAfter = 4 * time.Hour
|
||||
config.EscalateAfter = 48 * time.Hour
|
||||
|
||||
lookup := &stubChannelLookup{channelID: 1}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
// Run check twice
|
||||
worker.checkStaleMessages(ctx)
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify only ONE escalation was sent
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND channel_id = 1 AND body LIKE '%ESCALATION%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query escalations: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 escalation (no duplicates), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStalemateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envVars map[string]string
|
||||
expected StalemateConfig
|
||||
}{
|
||||
{
|
||||
name: "defaults when no env vars",
|
||||
envVars: map[string]string{},
|
||||
expected: DefaultStalemateConfig(),
|
||||
},
|
||||
{
|
||||
name: "custom values with day format",
|
||||
envVars: map[string]string{
|
||||
"SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT": "7d",
|
||||
"SYNAPBUS_STALEMATE_REMINDER_AFTER": "8h",
|
||||
"SYNAPBUS_STALEMATE_ESCALATE_AFTER": "3d",
|
||||
"SYNAPBUS_STALEMATE_INTERVAL": "30m",
|
||||
},
|
||||
expected: StalemateConfig{
|
||||
ProcessingTimeout: 7 * 24 * time.Hour,
|
||||
ReminderAfter: 8 * time.Hour,
|
||||
EscalateAfter: 3 * 24 * time.Hour,
|
||||
Interval: 30 * time.Minute,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "standard Go duration format",
|
||||
envVars: map[string]string{
|
||||
"SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT": "48h",
|
||||
"SYNAPBUS_STALEMATE_REMINDER_AFTER": "2h30m",
|
||||
"SYNAPBUS_STALEMATE_ESCALATE_AFTER": "72h",
|
||||
"SYNAPBUS_STALEMATE_INTERVAL": "5m",
|
||||
},
|
||||
expected: StalemateConfig{
|
||||
ProcessingTimeout: 48 * time.Hour,
|
||||
ReminderAfter: 2*time.Hour + 30*time.Minute,
|
||||
EscalateAfter: 72 * time.Hour,
|
||||
Interval: 5 * time.Minute,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid values fall back to defaults",
|
||||
envVars: map[string]string{
|
||||
"SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT": "invalid",
|
||||
"SYNAPBUS_STALEMATE_REMINDER_AFTER": "bad",
|
||||
"SYNAPBUS_STALEMATE_ESCALATE_AFTER": "",
|
||||
"SYNAPBUS_STALEMATE_INTERVAL": "-5m",
|
||||
},
|
||||
expected: DefaultStalemateConfig(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clear all env vars first
|
||||
envKeys := []string{
|
||||
"SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT",
|
||||
"SYNAPBUS_STALEMATE_REMINDER_AFTER",
|
||||
"SYNAPBUS_STALEMATE_ESCALATE_AFTER",
|
||||
"SYNAPBUS_STALEMATE_INTERVAL",
|
||||
}
|
||||
for _, k := range envKeys {
|
||||
os.Unsetenv(k)
|
||||
}
|
||||
|
||||
// Set test env vars
|
||||
for k, v := range tt.envVars {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
defer func() {
|
||||
for _, k := range envKeys {
|
||||
os.Unsetenv(k)
|
||||
}
|
||||
}()
|
||||
|
||||
cfg := ParseStalemateConfig()
|
||||
|
||||
if cfg.ProcessingTimeout != tt.expected.ProcessingTimeout {
|
||||
t.Errorf("ProcessingTimeout = %v, want %v", cfg.ProcessingTimeout, tt.expected.ProcessingTimeout)
|
||||
}
|
||||
if cfg.ReminderAfter != tt.expected.ReminderAfter {
|
||||
t.Errorf("ReminderAfter = %v, want %v", cfg.ReminderAfter, tt.expected.ReminderAfter)
|
||||
}
|
||||
if cfg.EscalateAfter != tt.expected.EscalateAfter {
|
||||
t.Errorf("EscalateAfter = %v, want %v", cfg.EscalateAfter, tt.expected.EscalateAfter)
|
||||
}
|
||||
if cfg.Interval != tt.expected.Interval {
|
||||
t.Errorf("Interval = %v, want %v", cfg.Interval, tt.expected.Interval)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationWithDays(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"7 days", "7d", 7 * 24 * time.Hour, false},
|
||||
{"1 day", "1d", 24 * time.Hour, false},
|
||||
{"30 days", "30d", 30 * 24 * time.Hour, false},
|
||||
{"standard hours", "48h", 48 * time.Hour, false},
|
||||
{"standard minutes", "15m", 15 * time.Minute, false},
|
||||
{"mixed duration", "2h30m", 2*time.Hour + 30*time.Minute, false},
|
||||
{"empty string", "", 0, true},
|
||||
{"invalid", "xyz", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseDurationWithDays(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseDurationWithDays(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseDurationWithDays(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"truncated", "hello world, this is a long message", 10, "hello worl..."},
|
||||
{"empty", "", 10, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := truncate(tt.input, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{"minutes", 30 * time.Minute, "30m"},
|
||||
{"hours", 5 * time.Hour, "5h"},
|
||||
{"1 day", 24 * time.Hour, "1 day"},
|
||||
{"2 days", 48 * time.Hour, "2 days"},
|
||||
{"1 day with hours", 25 * time.Hour, "1 day 1h"},
|
||||
{"2 days with hours", 50 * time.Hour, "2 days 2h"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatAge(tt.d)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatAge(%v) = %q, want %q", tt.d, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -492,6 +492,57 @@ func (s *SQLiteMessageStore) buildSearchConditions(agentName, query string, opts
|
||||
args = append(args, opts.Channel)
|
||||
}
|
||||
|
||||
if len(opts.Channels) > 0 {
|
||||
placeholders := make([]string, len(opts.Channels))
|
||||
for i, ch := range opts.Channels {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, ch)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("m.channel_id IN (SELECT id FROM channels WHERE LOWER(name) IN (%s))", strings.Join(placeholders, ",")))
|
||||
}
|
||||
|
||||
if len(opts.ExcludeChannels) > 0 {
|
||||
placeholders := make([]string, len(opts.ExcludeChannels))
|
||||
for i, ch := range opts.ExcludeChannels {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, ch)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("(m.channel_id IS NULL OR m.channel_id NOT IN (SELECT id FROM channels WHERE LOWER(name) IN (%s)))", strings.Join(placeholders, ",")))
|
||||
}
|
||||
|
||||
if len(opts.Agents) > 0 {
|
||||
placeholders := make([]string, len(opts.Agents))
|
||||
for i, a := range opts.Agents {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
// Clone placeholders for the second IN clause
|
||||
placeholders2 := make([]string, len(opts.Agents))
|
||||
for i, a := range opts.Agents {
|
||||
placeholders2[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause2 := strings.Join(placeholders2, ",")
|
||||
conditions = append(conditions, fmt.Sprintf("(m.from_agent IN (%s) OR m.to_agent IN (%s))", inClause, inClause2))
|
||||
}
|
||||
|
||||
if len(opts.ExcludeAgents) > 0 {
|
||||
placeholders := make([]string, len(opts.ExcludeAgents))
|
||||
for i, a := range opts.ExcludeAgents {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
placeholders2 := make([]string, len(opts.ExcludeAgents))
|
||||
for i, a := range opts.ExcludeAgents {
|
||||
placeholders2[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause2 := strings.Join(placeholders2, ",")
|
||||
conditions = append(conditions, fmt.Sprintf("m.from_agent NOT IN (%s) AND (m.to_agent = '' OR m.to_agent NOT IN (%s))", inClause, inClause2))
|
||||
}
|
||||
|
||||
if opts.After != "" {
|
||||
if t, err := time.Parse(time.RFC3339, opts.After); err == nil {
|
||||
conditions = append(conditions, "m.created_at >= ?")
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
// Service manages Web Push notifications and VAPID key lifecycle.
|
||||
type Service struct {
|
||||
store Store
|
||||
vapidKeys *VAPIDKeys
|
||||
dataDir string
|
||||
logger *slog.Logger
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// VAPIDKeys holds the VAPID key pair for Web Push authentication.
|
||||
type VAPIDKeys struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
// Notification represents a push notification payload.
|
||||
type Notification struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// NewService creates a new push notification service.
|
||||
// It loads or generates VAPID keys from the data directory.
|
||||
func NewService(store Store, dataDir string, logger *slog.Logger) (*Service, error) {
|
||||
s := &Service{
|
||||
store: store,
|
||||
dataDir: dataDir,
|
||||
logger: logger.With("component", "push"),
|
||||
}
|
||||
|
||||
keys, err := s.loadOrGenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load or generate VAPID keys: %w", err)
|
||||
}
|
||||
s.vapidKeys = keys
|
||||
|
||||
s.logger.Info("push notification service initialized",
|
||||
"vapid_public_key", keys.PublicKey[:16]+"...",
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// GetVAPIDPublicKey returns the VAPID public key for client subscription.
|
||||
func (s *Service) GetVAPIDPublicKey() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.vapidKeys.PublicKey
|
||||
}
|
||||
|
||||
// Subscribe registers a push subscription for a user.
|
||||
func (s *Service) Subscribe(ctx context.Context, userID int64, endpoint, keyP256dh, keyAuth, userAgent string) error {
|
||||
return s.store.Subscribe(ctx, userID, endpoint, keyP256dh, keyAuth, userAgent)
|
||||
}
|
||||
|
||||
// Unsubscribe removes a push subscription by endpoint, scoped to user.
|
||||
func (s *Service) Unsubscribe(ctx context.Context, userID int64, endpoint string) error {
|
||||
return s.store.Unsubscribe(ctx, userID, endpoint)
|
||||
}
|
||||
|
||||
// SendToUser sends a push notification to all subscriptions for a user.
|
||||
// It automatically removes subscriptions that return 410 Gone (unsubscribed).
|
||||
func (s *Service) SendToUser(ctx context.Context, userID int64, notification Notification) error {
|
||||
subs, err := s.store.GetSubscriptions(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get subscriptions: %w", err)
|
||||
}
|
||||
|
||||
if len(subs) == 0 {
|
||||
s.logger.Debug("no push subscriptions for user", "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(notification)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal notification: %w", err)
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
vapidPrivate := s.vapidKeys.PrivateKey
|
||||
vapidPublic := s.vapidKeys.PublicKey
|
||||
s.mu.RUnlock()
|
||||
|
||||
var sendErrors []error
|
||||
for _, sub := range subs {
|
||||
wpSub := &webpush.Subscription{
|
||||
Endpoint: sub.Endpoint,
|
||||
Keys: webpush.Keys{
|
||||
P256dh: sub.KeyP256dh,
|
||||
Auth: sub.KeyAuth,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := webpush.SendNotification(payload, wpSub, &webpush.Options{
|
||||
VAPIDPrivateKey: vapidPrivate,
|
||||
VAPIDPublicKey: vapidPublic,
|
||||
Subscriber: "mailto:noreply@synapbus.local",
|
||||
TTL: 86400, // 24 hours
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("push notification send failed",
|
||||
"user_id", userID,
|
||||
"endpoint", truncateEndpoint(sub.Endpoint),
|
||||
"error", err,
|
||||
)
|
||||
sendErrors = append(sendErrors, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Remove stale subscriptions
|
||||
if resp.StatusCode == http.StatusGone {
|
||||
s.logger.Info("removing stale push subscription",
|
||||
"user_id", userID,
|
||||
"endpoint", truncateEndpoint(sub.Endpoint),
|
||||
)
|
||||
if delErr := s.store.DeleteSubscription(ctx, sub.Endpoint); delErr != nil {
|
||||
s.logger.Warn("failed to delete stale subscription",
|
||||
"endpoint", truncateEndpoint(sub.Endpoint),
|
||||
"error", delErr,
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
s.logger.Warn("push notification rejected",
|
||||
"user_id", userID,
|
||||
"endpoint", truncateEndpoint(sub.Endpoint),
|
||||
"status", resp.StatusCode,
|
||||
)
|
||||
sendErrors = append(sendErrors, fmt.Errorf("push endpoint returned %d", resp.StatusCode))
|
||||
|
||||
// Also remove subscriptions that return 404 (endpoint no longer valid)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
if delErr := s.store.DeleteSubscription(ctx, sub.Endpoint); delErr != nil {
|
||||
s.logger.Warn("failed to delete invalid subscription",
|
||||
"endpoint", truncateEndpoint(sub.Endpoint),
|
||||
"error", delErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
s.logger.Debug("push notification sent",
|
||||
"user_id", userID,
|
||||
"endpoint", truncateEndpoint(sub.Endpoint),
|
||||
"status", resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
if len(sendErrors) > 0 {
|
||||
return fmt.Errorf("failed to send to %d/%d subscriptions", len(sendErrors), len(subs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vapidKeysPath returns the file path for the VAPID keys JSON file.
|
||||
func (s *Service) vapidKeysPath() string {
|
||||
return filepath.Join(s.dataDir, "vapid_keys.json")
|
||||
}
|
||||
|
||||
// loadOrGenerateVAPIDKeys loads existing VAPID keys from disk or generates new ones.
|
||||
func (s *Service) loadOrGenerateVAPIDKeys() (*VAPIDKeys, error) {
|
||||
keysPath := s.vapidKeysPath()
|
||||
|
||||
// Try to load existing keys
|
||||
data, err := os.ReadFile(keysPath)
|
||||
if err == nil {
|
||||
var keys VAPIDKeys
|
||||
if err := json.Unmarshal(data, &keys); err == nil && keys.PublicKey != "" && keys.PrivateKey != "" {
|
||||
s.logger.Info("loaded existing VAPID keys", "path", keysPath)
|
||||
return &keys, nil
|
||||
}
|
||||
s.logger.Warn("invalid VAPID keys file, regenerating", "path", keysPath)
|
||||
}
|
||||
|
||||
// Generate new VAPID keys
|
||||
keys, err := generateVAPIDKeys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate VAPID keys: %w", err)
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
if err := os.MkdirAll(s.dataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
|
||||
// Save to disk
|
||||
data, err = json.MarshalIndent(keys, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal VAPID keys: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(keysPath, data, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write VAPID keys: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("generated new VAPID keys", "path", keysPath)
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// generateVAPIDKeys creates a new ECDSA P-256 key pair and encodes them
|
||||
// as uncompressed (public) and raw (private) base64url strings for VAPID.
|
||||
func generateVAPIDKeys() (*VAPIDKeys, error) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate ECDSA key: %w", err)
|
||||
}
|
||||
|
||||
// Encode public key as uncompressed point (0x04 || x || y)
|
||||
pubBytes := elliptic.Marshal(elliptic.P256(), privateKey.PublicKey.X, privateKey.PublicKey.Y)
|
||||
publicKeyB64 := base64.RawURLEncoding.EncodeToString(pubBytes)
|
||||
|
||||
// Encode private key as raw big-endian bytes (32 bytes, zero-padded)
|
||||
privBytes := privateKey.D.Bytes()
|
||||
// Pad to 32 bytes if needed
|
||||
padded := make([]byte, 32)
|
||||
copy(padded[32-len(privBytes):], privBytes)
|
||||
privateKeyB64 := base64.RawURLEncoding.EncodeToString(padded)
|
||||
|
||||
return &VAPIDKeys{
|
||||
PublicKey: publicKeyB64,
|
||||
PrivateKey: privateKeyB64,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseVAPIDPrivateKey decodes a base64url-encoded VAPID private key
|
||||
// into an *ecdsa.PrivateKey. Useful for testing.
|
||||
func ParseVAPIDPrivateKey(b64 string) (*ecdsa.PrivateKey, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode private key: %w", err)
|
||||
}
|
||||
|
||||
d := new(big.Int).SetBytes(raw)
|
||||
curve := elliptic.P256()
|
||||
x, y := curve.ScalarBaseMult(raw)
|
||||
|
||||
return &ecdsa.PrivateKey{
|
||||
PublicKey: ecdsa.PublicKey{
|
||||
Curve: curve,
|
||||
X: x,
|
||||
Y: y,
|
||||
},
|
||||
D: d,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// truncateEndpoint returns a truncated version of the endpoint URL for logging.
|
||||
func truncateEndpoint(endpoint string) string {
|
||||
if len(endpoint) <= 40 {
|
||||
return endpoint
|
||||
}
|
||||
return endpoint[:37] + "..."
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockStore implements Store for testing without a database.
|
||||
type mockStore struct {
|
||||
subscriptions map[int64][]Subscription
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func newMockStore() *mockStore {
|
||||
return &mockStore{
|
||||
subscriptions: make(map[int64][]Subscription),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStore) Subscribe(_ context.Context, userID int64, endpoint, keyP256dh, keyAuth, userAgent string) error {
|
||||
// Upsert: remove existing endpoint first
|
||||
subs := m.subscriptions[userID]
|
||||
for i, s := range subs {
|
||||
if s.Endpoint == endpoint {
|
||||
subs = append(subs[:i], subs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
m.subscriptions[userID] = append(subs, Subscription{
|
||||
UserID: userID,
|
||||
Endpoint: endpoint,
|
||||
KeyP256dh: keyP256dh,
|
||||
KeyAuth: keyAuth,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStore) Unsubscribe(_ context.Context, userID int64, endpoint string) error {
|
||||
subs := m.subscriptions[userID]
|
||||
for i, s := range subs {
|
||||
if s.Endpoint == endpoint {
|
||||
m.subscriptions[userID] = append(subs[:i], subs[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStore) GetSubscriptions(_ context.Context, userID int64) ([]Subscription, error) {
|
||||
return m.subscriptions[userID], nil
|
||||
}
|
||||
|
||||
func (m *mockStore) DeleteSubscription(_ context.Context, endpoint string) error {
|
||||
m.deleted = append(m.deleted, endpoint)
|
||||
// Find and remove from any user's subscriptions
|
||||
for uid, subs := range m.subscriptions {
|
||||
for i, s := range subs {
|
||||
if s.Endpoint == endpoint {
|
||||
m.subscriptions[uid] = append(subs[:i], subs[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestVAPIDKeyGeneration(t *testing.T) {
|
||||
keys, err := generateVAPIDKeys()
|
||||
if err != nil {
|
||||
t.Fatalf("generateVAPIDKeys: %v", err)
|
||||
}
|
||||
|
||||
if keys.PublicKey == "" {
|
||||
t.Error("public key is empty")
|
||||
}
|
||||
if keys.PrivateKey == "" {
|
||||
t.Error("private key is empty")
|
||||
}
|
||||
|
||||
// Public key should be 65 bytes (uncompressed point) base64url encoded
|
||||
// 65 bytes -> ceil(65*4/3) = 87 chars without padding
|
||||
if len(keys.PublicKey) < 80 {
|
||||
t.Errorf("public key too short: %d chars", len(keys.PublicKey))
|
||||
}
|
||||
|
||||
// Private key should be 32 bytes base64url encoded
|
||||
// 32 bytes -> ceil(32*4/3) = 43 chars without padding
|
||||
if len(keys.PrivateKey) < 40 {
|
||||
t.Errorf("private key too short: %d chars", len(keys.PrivateKey))
|
||||
}
|
||||
|
||||
// Should be parseable back into an ECDSA key
|
||||
privKey, err := ParseVAPIDPrivateKey(keys.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseVAPIDPrivateKey: %v", err)
|
||||
}
|
||||
if privKey.Curve == nil {
|
||||
t.Error("parsed key has nil curve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVAPIDKeyPersistence(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.Default()
|
||||
store := newMockStore()
|
||||
|
||||
// First service creation should generate keys
|
||||
svc1, err := NewService(store, tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
key1 := svc1.GetVAPIDPublicKey()
|
||||
if key1 == "" {
|
||||
t.Fatal("VAPID public key is empty")
|
||||
}
|
||||
|
||||
// Verify file was written
|
||||
keysPath := filepath.Join(tmpDir, "vapid_keys.json")
|
||||
data, err := os.ReadFile(keysPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read VAPID keys file: %v", err)
|
||||
}
|
||||
|
||||
var savedKeys VAPIDKeys
|
||||
if err := json.Unmarshal(data, &savedKeys); err != nil {
|
||||
t.Fatalf("unmarshal saved keys: %v", err)
|
||||
}
|
||||
if savedKeys.PublicKey != key1 {
|
||||
t.Errorf("saved public key = %q, service key = %q", savedKeys.PublicKey, key1)
|
||||
}
|
||||
|
||||
// Second service creation should load the same keys
|
||||
svc2, err := NewService(store, tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService (second): %v", err)
|
||||
}
|
||||
key2 := svc2.GetVAPIDPublicKey()
|
||||
if key2 != key1 {
|
||||
t.Errorf("second service has different key: %q vs %q", key2, key1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVAPIDKeyFilePermissions(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.Default()
|
||||
store := newMockStore()
|
||||
|
||||
_, err := NewService(store, tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
keysPath := filepath.Join(tmpDir, "vapid_keys.json")
|
||||
info, err := os.Stat(keysPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat VAPID keys file: %v", err)
|
||||
}
|
||||
|
||||
perm := info.Mode().Perm()
|
||||
if perm&0o077 != 0 {
|
||||
t.Errorf("VAPID keys file has overly permissive mode: %o (want 0600)", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToUser_NoSubscriptions(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.Default()
|
||||
store := newMockStore()
|
||||
|
||||
svc, err := NewService(store, tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
// Sending to a user with no subscriptions should not error
|
||||
err = svc.SendToUser(context.Background(), 999, Notification{
|
||||
Title: "Test",
|
||||
Body: "Test body",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("SendToUser with no subs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// generateTestSubscriptionKeys creates a valid ECDSA P-256 key pair
|
||||
// encoded as base64url for use as Web Push subscription keys in tests.
|
||||
func generateTestSubscriptionKeys(t *testing.T) (p256dh, auth string) {
|
||||
t.Helper()
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate test key: %v", err)
|
||||
}
|
||||
pubBytes := elliptic.Marshal(elliptic.P256(), privKey.PublicKey.X, privKey.PublicKey.Y)
|
||||
p256dh = base64.RawURLEncoding.EncodeToString(pubBytes)
|
||||
|
||||
authBytes := make([]byte, 16)
|
||||
rand.Read(authBytes)
|
||||
auth = base64.RawURLEncoding.EncodeToString(authBytes)
|
||||
return
|
||||
}
|
||||
|
||||
func TestSendToUser_GoneSubscription(t *testing.T) {
|
||||
// Set up a fake push endpoint that returns 410 Gone
|
||||
var requestCount atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount.Add(1)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.Default()
|
||||
store := newMockStore()
|
||||
|
||||
svc, err := NewService(store, tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
p256dh, authKey := generateTestSubscriptionKeys(t)
|
||||
store.Subscribe(context.Background(), 1, server.URL+"/push", p256dh, authKey, "TestAgent")
|
||||
|
||||
// Send notification — should succeed but remove the stale subscription
|
||||
_ = svc.SendToUser(context.Background(), 1, Notification{
|
||||
Title: "Test",
|
||||
Body: "Test body",
|
||||
})
|
||||
|
||||
if requestCount.Load() == 0 {
|
||||
t.Fatal("expected at least one request to push endpoint")
|
||||
}
|
||||
|
||||
// The stale subscription should have been deleted
|
||||
if len(store.deleted) != 1 {
|
||||
t.Errorf("expected 1 deleted subscription, got %d", len(store.deleted))
|
||||
}
|
||||
if len(store.deleted) > 0 && store.deleted[0] != server.URL+"/push" {
|
||||
t.Errorf("deleted wrong endpoint: %q", store.deleted[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToUser_NotFoundSubscription(t *testing.T) {
|
||||
// Set up a fake push endpoint that returns 404
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.Default()
|
||||
store := newMockStore()
|
||||
|
||||
svc, err := NewService(store, tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
p256dh, authKey := generateTestSubscriptionKeys(t)
|
||||
store.Subscribe(context.Background(), 1, server.URL+"/push", p256dh, authKey, "TestAgent")
|
||||
|
||||
_ = svc.SendToUser(context.Background(), 1, Notification{
|
||||
Title: "Test",
|
||||
Body: "Test body",
|
||||
})
|
||||
|
||||
// 404 should also trigger deletion
|
||||
if len(store.deleted) != 1 {
|
||||
t.Errorf("expected 1 deleted subscription, got %d", len(store.deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSubscribeUnsubscribe(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.Default()
|
||||
store := newMockStore()
|
||||
|
||||
svc, err := NewService(store, tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Subscribe
|
||||
err = svc.Subscribe(ctx, 1, "https://push.example.com/test", "p256dh", "auth", "Agent")
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
|
||||
subs := store.subscriptions[1]
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("got %d subscriptions, want 1", len(subs))
|
||||
}
|
||||
|
||||
// Unsubscribe
|
||||
err = svc.Unsubscribe(ctx, 1, "https://push.example.com/test")
|
||||
if err != nil {
|
||||
t.Fatalf("Unsubscribe: %v", err)
|
||||
}
|
||||
|
||||
subs = store.subscriptions[1]
|
||||
if len(subs) != 0 {
|
||||
t.Errorf("got %d subscriptions after unsubscribe, want 0", len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateEndpoint(t *testing.T) {
|
||||
short := "https://short.url"
|
||||
long := "https://push.services.mozilla.com/wpush/v2/very-long-subscription-id-that-goes-on-and-on"
|
||||
|
||||
got := truncateEndpoint(short)
|
||||
if got != short {
|
||||
t.Errorf("truncateEndpoint(short) = %q, want %q", got, short)
|
||||
}
|
||||
|
||||
got = truncateEndpoint(long)
|
||||
if len(got) != 40 {
|
||||
t.Errorf("truncateEndpoint(long) length = %d, want 40", len(got))
|
||||
}
|
||||
if got[len(got)-3:] != "..." {
|
||||
t.Errorf("truncateEndpoint(long) should end with '...', got %q", got)
|
||||
}
|
||||
|
||||
got = truncateEndpoint("")
|
||||
if got != "" {
|
||||
t.Errorf("truncateEndpoint('') = %q, want ''", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package push provides Web Push notification support for SynapBus.
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Store defines the persistence interface for push subscriptions.
|
||||
type Store interface {
|
||||
// Subscribe registers a push subscription for a user.
|
||||
// If the endpoint already exists, it updates the keys and user agent.
|
||||
Subscribe(ctx context.Context, userID int64, endpoint, keyP256dh, keyAuth, userAgent string) error
|
||||
|
||||
// Unsubscribe removes a push subscription by endpoint, scoped to user.
|
||||
Unsubscribe(ctx context.Context, userID int64, endpoint string) error
|
||||
|
||||
// GetSubscriptions returns all push subscriptions for a user.
|
||||
GetSubscriptions(ctx context.Context, userID int64) ([]Subscription, error)
|
||||
|
||||
// DeleteSubscription removes a push subscription by endpoint.
|
||||
// This is used to clean up stale subscriptions (e.g., 410 Gone responses).
|
||||
DeleteSubscription(ctx context.Context, endpoint string) error
|
||||
}
|
||||
|
||||
// Subscription represents a Web Push subscription stored in the database.
|
||||
type Subscription struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Endpoint string
|
||||
KeyP256dh string
|
||||
KeyAuth string
|
||||
UserAgent string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// SQLiteStore implements Store using SQLite.
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteStore creates a new SQLite-backed push subscription store.
|
||||
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
|
||||
return &SQLiteStore{db: db}
|
||||
}
|
||||
|
||||
// Subscribe registers or updates a push subscription for a user.
|
||||
func (s *SQLiteStore) Subscribe(ctx context.Context, userID int64, endpoint, keyP256dh, keyAuth, userAgent string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, key_p256dh, key_auth, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
key_p256dh = excluded.key_p256dh,
|
||||
key_auth = excluded.key_auth,
|
||||
user_agent = excluded.user_agent,
|
||||
user_id = excluded.user_id`,
|
||||
userID, endpoint, keyP256dh, keyAuth, userAgent,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Unsubscribe removes a push subscription by endpoint, scoped to the owning user.
|
||||
func (s *SQLiteStore) Unsubscribe(ctx context.Context, userID int64, endpoint string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?`,
|
||||
userID, endpoint,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSubscriptions returns all push subscriptions for a user.
|
||||
func (s *SQLiteStore) GetSubscriptions(ctx context.Context, userID int64) ([]Subscription, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, user_id, endpoint, key_p256dh, key_auth, user_agent, created_at
|
||||
FROM push_subscriptions
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subs []Subscription
|
||||
for rows.Next() {
|
||||
var sub Subscription
|
||||
if err := rows.Scan(&sub.ID, &sub.UserID, &sub.Endpoint, &sub.KeyP256dh, &sub.KeyAuth, &sub.UserAgent, &sub.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
return subs, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteSubscription removes a push subscription by endpoint (any user).
|
||||
// Used for stale subscription cleanup (e.g., 410 Gone responses).
|
||||
func (s *SQLiteStore) DeleteSubscription(ctx context.Context, endpoint string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = ?`,
|
||||
endpoint,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
// Seed a test user
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testuser', 'hash', 'Test User')`)
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (2, 'otheruser', 'hash', 'Other User')`)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSQLiteStore_Subscribe(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
err := store.Subscribe(ctx, 1, "https://push.example.com/sub1", "p256dh-key-1", "auth-key-1", "TestAgent/1.0")
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
|
||||
subs, err := store.GetSubscriptions(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("got %d subscriptions, want 1", len(subs))
|
||||
}
|
||||
|
||||
sub := subs[0]
|
||||
if sub.Endpoint != "https://push.example.com/sub1" {
|
||||
t.Errorf("endpoint = %q, want %q", sub.Endpoint, "https://push.example.com/sub1")
|
||||
}
|
||||
if sub.KeyP256dh != "p256dh-key-1" {
|
||||
t.Errorf("key_p256dh = %q, want %q", sub.KeyP256dh, "p256dh-key-1")
|
||||
}
|
||||
if sub.KeyAuth != "auth-key-1" {
|
||||
t.Errorf("key_auth = %q, want %q", sub.KeyAuth, "auth-key-1")
|
||||
}
|
||||
if sub.UserAgent != "TestAgent/1.0" {
|
||||
t.Errorf("user_agent = %q, want %q", sub.UserAgent, "TestAgent/1.0")
|
||||
}
|
||||
if sub.UserID != 1 {
|
||||
t.Errorf("user_id = %d, want 1", sub.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_SubscribeUpsert(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// First subscription
|
||||
err := store.Subscribe(ctx, 1, "https://push.example.com/sub1", "old-p256dh", "old-auth", "OldAgent")
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
|
||||
// Update same endpoint with new keys
|
||||
err = store.Subscribe(ctx, 1, "https://push.example.com/sub1", "new-p256dh", "new-auth", "NewAgent")
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe upsert: %v", err)
|
||||
}
|
||||
|
||||
subs, err := store.GetSubscriptions(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("got %d subscriptions, want 1 (should upsert)", len(subs))
|
||||
}
|
||||
if subs[0].KeyP256dh != "new-p256dh" {
|
||||
t.Errorf("key_p256dh = %q, want %q", subs[0].KeyP256dh, "new-p256dh")
|
||||
}
|
||||
if subs[0].KeyAuth != "new-auth" {
|
||||
t.Errorf("key_auth = %q, want %q", subs[0].KeyAuth, "new-auth")
|
||||
}
|
||||
if subs[0].UserAgent != "NewAgent" {
|
||||
t.Errorf("user_agent = %q, want %q", subs[0].UserAgent, "NewAgent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_MultipleSubscriptions(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Register multiple subscriptions for same user
|
||||
for i := 0; i < 3; i++ {
|
||||
endpoint := fmt.Sprintf("https://push.example.com/sub%d", i)
|
||||
err := store.Subscribe(ctx, 1, endpoint, fmt.Sprintf("p256dh-%d", i), fmt.Sprintf("auth-%d", i), "Agent")
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
subs, err := store.GetSubscriptions(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) != 3 {
|
||||
t.Errorf("got %d subscriptions, want 3", len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_Unsubscribe(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
store.Subscribe(ctx, 1, "https://push.example.com/sub1", "p256dh", "auth", "Agent")
|
||||
store.Subscribe(ctx, 1, "https://push.example.com/sub2", "p256dh2", "auth2", "Agent")
|
||||
|
||||
err := store.Unsubscribe(ctx, 1, "https://push.example.com/sub1")
|
||||
if err != nil {
|
||||
t.Fatalf("Unsubscribe: %v", err)
|
||||
}
|
||||
|
||||
subs, err := store.GetSubscriptions(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("got %d subscriptions, want 1", len(subs))
|
||||
}
|
||||
if subs[0].Endpoint != "https://push.example.com/sub2" {
|
||||
t.Errorf("remaining endpoint = %q, want sub2", subs[0].Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_DeleteSubscription(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
store.Subscribe(ctx, 1, "https://push.example.com/sub1", "p256dh", "auth", "Agent")
|
||||
|
||||
err := store.DeleteSubscription(ctx, "https://push.example.com/sub1")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteSubscription: %v", err)
|
||||
}
|
||||
|
||||
subs, err := store.GetSubscriptions(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) != 0 {
|
||||
t.Errorf("got %d subscriptions, want 0", len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_UserIsolation(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
store.Subscribe(ctx, 1, "https://push.example.com/user1-sub", "p256dh1", "auth1", "Agent")
|
||||
store.Subscribe(ctx, 2, "https://push.example.com/user2-sub", "p256dh2", "auth2", "Agent")
|
||||
|
||||
subs1, err := store.GetSubscriptions(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscriptions(1): %v", err)
|
||||
}
|
||||
if len(subs1) != 1 {
|
||||
t.Errorf("user 1: got %d subscriptions, want 1", len(subs1))
|
||||
}
|
||||
|
||||
subs2, err := store.GetSubscriptions(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscriptions(2): %v", err)
|
||||
}
|
||||
if len(subs2) != 1 {
|
||||
t.Errorf("user 2: got %d subscriptions, want 1", len(subs2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_GetSubscriptionsEmpty(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
subs, err := store.GetSubscriptions(ctx, 999)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscriptions: %v", err)
|
||||
}
|
||||
if subs != nil {
|
||||
t.Errorf("got %v, want nil for empty result", subs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_UnsubscribeNonexistent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Should not error when deleting non-existent endpoint
|
||||
err := store.Unsubscribe(ctx, 1, "https://push.example.com/nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("Unsubscribe non-existent: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- A2A inbound gateway: task tracking for external A2A agents sending tasks to SynapBus agents.
|
||||
CREATE TABLE IF NOT EXISTS a2a_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
context_id TEXT NOT NULL,
|
||||
target_agent TEXT NOT NULL,
|
||||
source_agent TEXT DEFAULT '',
|
||||
conversation_id INTEGER,
|
||||
state TEXT NOT NULL DEFAULT 'SUBMITTED',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_a2a_tasks_target ON a2a_tasks(target_agent);
|
||||
CREATE INDEX IF NOT EXISTS idx_a2a_tasks_state ON a2a_tasks(state);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- External identity provider support (GitHub, Google, Azure AD)
|
||||
-- Links external IdP accounts to local SynapBus users
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
raw_claims TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(provider, external_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_identities_user ON user_identities(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_identities_lookup ON user_identities(provider, external_id);
|
||||
|
||||
-- Add email column to users table for IdP linking
|
||||
ALTER TABLE users ADD COLUMN email TEXT;
|
||||
|
||||
INSERT INTO schema_migrations (version) VALUES (11);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Push notification subscriptions for Web Push API
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
key_p256dh TEXT NOT NULL,
|
||||
key_auth TEXT NOT NULL,
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
Vendored
+15
-11
@@ -4,33 +4,37 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>SynapBus</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.DpHKCwmv.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BRBotovi.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DBeLgT1-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/SAcaBy3_.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DL-Ee-iM.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BCvik_Lu.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BdrVqzRy.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.B_lhmyMs.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.DRWRIYgZ.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BdkwYcun.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CAWWRzNH.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B-GqcZrE.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DpGxxGDS.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/3PInzq4O.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/qakGnb92.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.BZim7ID1.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_vhg0t8 = {
|
||||
__sveltekit_1ygn49r = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.DpHKCwmv.js"),
|
||||
import("/_app/immutable/entry/app.B_lhmyMs.js")
|
||||
import("/_app/immutable/entry/start.DRWRIYgZ.js"),
|
||||
import("/_app/immutable/entry/app.BZim7ID1.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- A2A inbound gateway: task tracking for external A2A agents sending tasks to SynapBus agents.
|
||||
CREATE TABLE IF NOT EXISTS a2a_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
context_id TEXT NOT NULL,
|
||||
target_agent TEXT NOT NULL,
|
||||
source_agent TEXT DEFAULT '',
|
||||
conversation_id INTEGER,
|
||||
state TEXT NOT NULL DEFAULT 'SUBMITTED',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_a2a_tasks_target ON a2a_tasks(target_agent);
|
||||
CREATE INDEX IF NOT EXISTS idx_a2a_tasks_state ON a2a_tasks(state);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- External identity provider support (GitHub, Google, Azure AD)
|
||||
-- Links external IdP accounts to local SynapBus users
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
raw_claims TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(provider, external_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_identities_user ON user_identities(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_identities_lookup ON user_identities(provider, external_id);
|
||||
|
||||
-- Add email column to users table for IdP linking
|
||||
ALTER TABLE users ADD COLUMN email TEXT;
|
||||
|
||||
INSERT INTO schema_migrations (version) VALUES (11);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Push notification subscriptions for Web Push API
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
key_p256dh TEXT NOT NULL,
|
||||
key_auth TEXT NOT NULL,
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.synapbus/synapbus",
|
||||
"description": "MCP-native agent-to-agent messaging hub with channels, DMs, and semantic search",
|
||||
"repository": {
|
||||
"url": "https://github.com/synapbus/synapbus",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.7.0",
|
||||
"packages": []
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Specification Quality Checklist: SynapBus v0.6.0 Platform Features Bundle
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-16
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All 8 features specified with P1/P2/P3 priority levels
|
||||
- 20 functional requirements defined
|
||||
- 9 success criteria established
|
||||
- 6 edge cases documented
|
||||
- 12 assumptions recorded
|
||||
- Spec is ready for /speckit.plan
|
||||
@@ -0,0 +1,67 @@
|
||||
# A2A Endpoint Contract
|
||||
|
||||
## Agent Card Discovery
|
||||
|
||||
```
|
||||
GET /.well-known/agent-card.json
|
||||
Response: 200 OK
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Response body: A2A Agent Card v1.0 with:
|
||||
- `name`: "SynapBus Hub"
|
||||
- `description`: instance description
|
||||
- `skills[]`: one per active agent (id=agent.name, name=agent.display_name, description from capabilities)
|
||||
- `security_schemes`: apiKey (Bearer header) + oauth2
|
||||
- `supported_interfaces[0].url`: `{base_url}/a2a`
|
||||
- `supported_interfaces[0].protocol_binding`: "JSONRPC"
|
||||
|
||||
## JSON-RPC Endpoint
|
||||
|
||||
```
|
||||
POST /a2a
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <api-key-or-oauth-token>
|
||||
```
|
||||
|
||||
### message.send
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "message.send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"text": "Research MCP security patterns"}],
|
||||
"metadata": {"target_agent": "research-mcpproxy"}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"task": {
|
||||
"id": "uuid-here",
|
||||
"state": "SUBMITTED",
|
||||
"context_id": "ctx-uuid",
|
||||
"history": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### tasks.get
|
||||
|
||||
Request: `{"jsonrpc":"2.0","id":2,"method":"tasks.get","params":{"id":"task-uuid"}}`
|
||||
|
||||
### tasks.cancel
|
||||
|
||||
Request: `{"jsonrpc":"2.0","id":3,"method":"tasks.cancel","params":{"id":"task-uuid"}}`
|
||||
@@ -0,0 +1,40 @@
|
||||
# Identity Provider Routes Contract
|
||||
|
||||
## List Providers
|
||||
|
||||
```
|
||||
GET /auth/providers
|
||||
Response: 200 OK
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{"id": "github", "type": "github", "display_name": "GitHub"},
|
||||
{"id": "google", "type": "oidc", "display_name": "Google"},
|
||||
{"id": "azuread-gcore", "type": "oidc", "display_name": "Microsoft (Gcore)"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Returns empty array if no IdPs configured. Only enabled providers are returned.
|
||||
|
||||
## Initiate Login
|
||||
|
||||
```
|
||||
GET /auth/login/{provider}
|
||||
Response: 302 Redirect to external IdP authorization URL
|
||||
```
|
||||
|
||||
Sets a `state` cookie for CSRF protection. Redirects to the IdP's authorization endpoint with configured client_id, redirect_uri, and scopes.
|
||||
|
||||
## Callback
|
||||
|
||||
```
|
||||
GET /auth/callback/{provider}?code=...&state=...
|
||||
Response: 302 Redirect to /
|
||||
```
|
||||
|
||||
Validates state parameter, exchanges code for tokens, fetches/verifies user identity, creates or links local user, sets session cookie, redirects to Web UI home.
|
||||
|
||||
Error case: Redirects to `/login?error=<reason>`.
|
||||
@@ -0,0 +1,25 @@
|
||||
# StalemateWorker Configuration Contract
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT | 24h | Auto-fail processing DMs older than this |
|
||||
| SYNAPBUS_STALEMATE_REMINDER_AFTER | 4h | Send reminder for pending DMs older than this |
|
||||
| SYNAPBUS_STALEMATE_ESCALATE_AFTER | 48h | Escalate pending DMs to #approvals after this |
|
||||
| SYNAPBUS_STALEMATE_INTERVAL | 15m | How often the worker checks (minimum 1m) |
|
||||
|
||||
## Duration Format
|
||||
|
||||
Supports Go `time.ParseDuration` format: `24h`, `30m`, `1h30m`, etc.
|
||||
Also supports day suffix: `7d` = 168h.
|
||||
|
||||
## Behavior
|
||||
|
||||
1. Worker runs every INTERVAL (default 15m)
|
||||
2. Queries DMs only (`to_agent IS NOT NULL`)
|
||||
3. Skips messages where `from_agent = 'system'` or `to_agent = 'system'`
|
||||
4. For processing messages: checks `claimed_at + PROCESSING_TIMEOUT < now()`
|
||||
5. For pending messages: checks `created_at + REMINDER_AFTER < now()` (reminder) and `created_at + ESCALATE_AFTER < now()` (escalation)
|
||||
6. Avoids duplicate reminders by checking if a system reminder for this message already exists
|
||||
7. Escalation message includes: original sender, original body (truncated), age, target agent
|
||||
@@ -0,0 +1,74 @@
|
||||
# Data Model: SynapBus v0.6.0
|
||||
|
||||
## Existing Entities (Modified)
|
||||
|
||||
### Messages (existing table)
|
||||
- No schema changes. StalemateWorker queries existing `status`, `claimed_at`, `created_at`, `to_agent`, `from_agent` columns.
|
||||
- `reply_to` column already exists (added in migration 007_threads.sql).
|
||||
|
||||
### Agents (existing table)
|
||||
- `capabilities` column already exists (JSON, currently unused).
|
||||
- A2A Agent Cards will read from this column.
|
||||
- Admin CLI `agent update-capabilities` will write to this column.
|
||||
|
||||
## New Entities
|
||||
|
||||
### A2A Tasks (migration 010)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| id | TEXT (UUID) PRIMARY KEY | A2A task identifier |
|
||||
| context_id | TEXT | Groups related tasks |
|
||||
| target_agent | TEXT NOT NULL | SynapBus agent name |
|
||||
| source_agent | TEXT | External agent identifier |
|
||||
| conversation_id | INTEGER | Maps to SynapBus conversation |
|
||||
| state | TEXT NOT NULL | SUBMITTED, WORKING, COMPLETED, FAILED, CANCELED |
|
||||
| created_at | TIMESTAMP | Auto-set |
|
||||
| updated_at | TIMESTAMP | Auto-set on state change |
|
||||
|
||||
### User Identities (migration 011)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| id | INTEGER PRIMARY KEY | Auto-increment |
|
||||
| user_id | INTEGER FK → users(id) | Local user link |
|
||||
| provider | TEXT NOT NULL | 'github', 'google', 'azuread' |
|
||||
| external_id | TEXT NOT NULL | Provider's stable user ID |
|
||||
| email | TEXT | Email from provider |
|
||||
| display_name | TEXT | Name from provider |
|
||||
| raw_claims | TEXT DEFAULT '{}' | Full JSON claims |
|
||||
| created_at | TIMESTAMP | Auto-set |
|
||||
| UNIQUE(provider, external_id) | | |
|
||||
|
||||
### Identity Providers (migration 011)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| id | TEXT PRIMARY KEY | 'github', 'google', 'azuread-gcore' |
|
||||
| type | TEXT NOT NULL | 'github', 'oidc' |
|
||||
| display_name | TEXT NOT NULL | Button label |
|
||||
| client_id | TEXT NOT NULL | OAuth client ID |
|
||||
| client_secret_encrypted | TEXT NOT NULL | Encrypted secret |
|
||||
| issuer_url | TEXT | OIDC discovery URL (NULL for GitHub) |
|
||||
| scopes | TEXT DEFAULT '[]' | JSON array |
|
||||
| allowed_domains | TEXT DEFAULT '[]' | JSON array |
|
||||
| group_mapping | TEXT DEFAULT '{}' | JSON: external group → role |
|
||||
| tenant_id | TEXT | Azure AD tenant |
|
||||
| enabled | INTEGER DEFAULT 1 | Active flag |
|
||||
| created_at | TIMESTAMP | Auto-set |
|
||||
|
||||
## State Transitions
|
||||
|
||||
### A2A Task States
|
||||
```
|
||||
SUBMITTED → WORKING → COMPLETED (terminal)
|
||||
→ FAILED (terminal)
|
||||
→ CANCELED (terminal)
|
||||
```
|
||||
|
||||
### Message Stalemate Flow
|
||||
```
|
||||
pending ──(4h)──→ system reminder DM
|
||||
pending ──(48h)──→ escalation to #approvals
|
||||
processing ──(24h)──→ auto-fail with "claim timeout exceeded"
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# Implementation Plan: SynapBus v0.6.0 — Platform Features Bundle
|
||||
|
||||
**Branch**: `007-platform-features-bundle` | **Date**: 2026-03-16 | **Spec**: [spec.md](spec.md)
|
||||
|
||||
## Summary
|
||||
|
||||
8 features adding message lifecycle enforcement (StalemateWorker), channel reply threading, A2A protocol support (Agent Cards + inbound gateway), mobile-responsive Web UI, reactive K8s agent activation, enterprise identity providers (GitHub/Google/Azure AD), and CLAUDE.md communication protocol. All features are additive — existing functionality remains unchanged.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (per go.mod)
|
||||
**Primary Dependencies**: go-chi/chi (HTTP), mark3labs/mcp-go (MCP), ory/fosite (OAuth), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors). NEW: coreos/go-oidc/v3 (OIDC), golang.org/x/oauth2 (OAuth client)
|
||||
**Storage**: SQLite (modernc.org/sqlite, pure Go) — single DB file in `--data` directory
|
||||
**Testing**: `go test ./...` (Go), `npm run build` (Svelte)
|
||||
**Target Platform**: linux/amd64, darwin/arm64, darwin/amd64
|
||||
**Project Type**: web-service + CLI + embedded SPA
|
||||
**Constraints**: Zero CGO, single binary, pure Go dependencies only
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | All features add to the single binary. No external dependencies. |
|
||||
| II. MCP-Native | PASS | A2A is an additional protocol alongside MCP, not a replacement. MCP remains sole agent interface. |
|
||||
| III. Pure Go, Zero CGO | PASS | coreos/go-oidc and golang.org/x/oauth2 are pure Go. a2a-go SDK is pure Go. |
|
||||
| IV. Multi-Tenant with Ownership | PASS | All new features respect ownership model. |
|
||||
| V. Embedded OAuth 2.1 | PASS | Enterprise IdP is additive — local auth remains default. External IdPs are optional login providers, not replacements. |
|
||||
| VI. Semantic-Ready Storage | PASS | New tables use same SQLite DB. |
|
||||
| VII. Swarm Intelligence Patterns | PASS | K8s handlers extend existing dispatcher. |
|
||||
| VIII. Observable by Default | PASS | StalemateWorker actions are logged and traced. |
|
||||
| IX. Progressive Complexity | PASS | All features are opt-in. Basic messaging unchanged. |
|
||||
| X. Web UI as First-Class Citizen | PASS | Mobile-responsive improves the UI. |
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| A2A protocol (previously a Non-Goal) | User explicitly requested A2A support. Ecosystem has matured (v1.0, Linux Foundation). | Not adding A2A means SynapBus remains invisible to external agent frameworks. |
|
||||
| Enterprise IdP (adds external dependency at runtime) | Required for organizational deployment (Gcore uses Azure AD). | External IdPs are optional — SynapBus fully functions without them. |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/007-platform-features-bundle/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0: technical decisions
|
||||
├── data-model.md # Phase 1: entities and schema
|
||||
├── quickstart.md # Phase 1: developer setup guide
|
||||
├── contracts/ # Phase 1: API contracts
|
||||
│ ├── a2a-endpoint.md # A2A JSON-RPC contract
|
||||
│ ├── idp-routes.md # IdP callback routes
|
||||
│ └── stalemate-config.md # StalemateWorker config
|
||||
└── tasks.md # Phase 2: implementation tasks
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── messaging/
|
||||
│ └── stalemate.go # F1: StalemateWorker
|
||||
├── channels/
|
||||
│ └── service.go # F2: reply_to in BroadcastMessage
|
||||
├── a2a/ # F3+F5: A2A support (NEW)
|
||||
│ ├── agentcard.go # Agent Card generation
|
||||
│ ├── gateway.go # JSON-RPC handler
|
||||
│ ├── taskstore.go # A2A task state tracking
|
||||
│ └── routes.go # HTTP endpoint registration
|
||||
├── auth/
|
||||
│ └── idp/ # F7: Identity providers (NEW)
|
||||
│ ├── provider.go # Provider interface
|
||||
│ ├── github.go # GitHub OAuth
|
||||
│ ├── oidc.go # Generic OIDC (Google, Azure AD)
|
||||
│ ├── handlers.go # Callback HTTP handlers
|
||||
│ └── store.go # user_identities DB operations
|
||||
├── actions/
|
||||
│ └── registry.go # F2: add reply_to to send_channel_message
|
||||
├── mcp/
|
||||
│ └── bridge.go # F2: pass reply_to through bridge
|
||||
├── k8s/ # F6: extend existing handlers
|
||||
│ └── dispatcher.go # Add @mention event matching
|
||||
├── api/
|
||||
│ └── router.go # Wire new routes
|
||||
└── web/ # F4: mobile-responsive (embedded)
|
||||
|
||||
web/src/
|
||||
├── routes/
|
||||
│ ├── +layout.svelte # F4: responsive sidebar
|
||||
│ └── login/+page.svelte # F7: IdP buttons
|
||||
├── lib/components/
|
||||
│ ├── Sidebar.svelte # F4: drawer mode
|
||||
│ └── Header.svelte # F4: hamburger button
|
||||
|
||||
schema/
|
||||
├── 010_a2a_tasks.sql # F5: A2A task tracking
|
||||
└── 011_external_auth.sql # F7: user_identities, identity_providers
|
||||
|
||||
cmd/synapbus/
|
||||
├── main.go # Wire StalemateWorker, A2A, IdP
|
||||
└── admin.go # F3/F8: agent capabilities CLI
|
||||
|
||||
CLAUDE.md # F8: communication protocol
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing Go project layout with `internal/` packages. Two new packages (`internal/a2a/`, `internal/auth/idp/`) and two new migrations. All other changes extend existing files.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Quickstart: SynapBus v0.6.0 Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+ (for Svelte frontend)
|
||||
- Git
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build Go binary + Svelte SPA
|
||||
make test # Run all Go tests
|
||||
go test ./... # Alternative: all tests
|
||||
cd web && npm run build # Build frontend only
|
||||
```
|
||||
|
||||
## New Features Development
|
||||
|
||||
### F1: StalemateWorker
|
||||
- Edit: `internal/messaging/stalemate.go` (new file)
|
||||
- Test: `internal/messaging/stalemate_test.go`
|
||||
- Wire: `cmd/synapbus/main.go` (start worker after message service)
|
||||
- Config: env vars `SYNAPBUS_STALEMATE_*`
|
||||
|
||||
### F2: Channel reply_to
|
||||
- Edit: `internal/actions/registry.go` (add reply_to param to send_channel_message)
|
||||
- Edit: `internal/mcp/bridge.go` (pass reply_to in callSendChannelMessage)
|
||||
- Edit: `internal/channels/service.go` (accept reply_to in BroadcastMessage)
|
||||
- Test: `internal/channels/service_test.go`
|
||||
|
||||
### F3: A2A Agent Cards
|
||||
- New: `internal/a2a/agentcard.go`
|
||||
- Edit: `internal/api/router.go` (add /.well-known/agent-card.json route)
|
||||
- Edit: `cmd/synapbus/admin.go` (agent update-capabilities command)
|
||||
- Test: `internal/a2a/agentcard_test.go`
|
||||
|
||||
### F4: Mobile UI
|
||||
- Edit: `web/src/routes/+layout.svelte` (responsive sidebar)
|
||||
- Edit: `web/src/lib/components/Sidebar.svelte` (drawer mode)
|
||||
- Edit: `web/src/lib/components/Header.svelte` (hamburger button)
|
||||
- Test: Visual verification at 375px viewport
|
||||
|
||||
### F5: A2A Gateway
|
||||
- New: `internal/a2a/gateway.go`, `taskstore.go`, `routes.go`
|
||||
- New: `schema/010_a2a_tasks.sql`
|
||||
- Edit: `internal/api/router.go` or `cmd/synapbus/main.go` (mount /a2a)
|
||||
- Test: `internal/a2a/gateway_test.go`
|
||||
|
||||
### F6: K8s Handlers
|
||||
- Edit: `internal/k8s/dispatcher.go` (add mention event matching)
|
||||
- Edit: `cmd/synapbus/admin.go` (user-friendly register-handler command)
|
||||
- Test: `internal/k8s/dispatcher_test.go`
|
||||
|
||||
### F7: Enterprise IdP
|
||||
- New: `internal/auth/idp/` package
|
||||
- New: `schema/011_external_auth.sql`
|
||||
- Edit: `web/src/routes/login/+page.svelte` (IdP buttons)
|
||||
- Edit: `cmd/synapbus/main.go` (wire IdP routes)
|
||||
- Test: `internal/auth/idp/idp_test.go`
|
||||
|
||||
### F8: CLAUDE.md
|
||||
- Edit: `CLAUDE.md` (add SynapBus Communication Protocol section)
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
./synapbus serve --port 8080 --data ./data --log-level debug
|
||||
```
|
||||
|
||||
## Testing StalemateWorker
|
||||
|
||||
```bash
|
||||
# Set short timeouts for testing
|
||||
SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT=1m \
|
||||
SYNAPBUS_STALEMATE_REMINDER_AFTER=30s \
|
||||
SYNAPBUS_STALEMATE_ESCALATE_AFTER=2m \
|
||||
SYNAPBUS_STALEMATE_INTERVAL=10s \
|
||||
./synapbus serve --port 8080 --data ./test-data
|
||||
```
|
||||
|
||||
## Testing A2A
|
||||
|
||||
```bash
|
||||
# Fetch Agent Card
|
||||
curl http://localhost:8080/.well-known/agent-card.json | jq
|
||||
|
||||
# Send A2A task
|
||||
curl -X POST http://localhost:8080/a2a \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <api-key>" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"message.send","params":{"message":{"role":"user","parts":[{"text":"Hello"}],"metadata":{"target_agent":"test-bot"}}}}'
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# Research: SynapBus v0.6.0 Platform Features
|
||||
|
||||
## R1: StalemateWorker Pattern
|
||||
|
||||
**Decision**: Follow ExpiryWorker/RetentionWorker pattern — background goroutine with configurable interval.
|
||||
**Rationale**: Consistent with existing codebase. ExpiryWorker (`internal/channels/expiry.go`) runs every 1min. RetentionWorker (`internal/messaging/retention.go`) runs every 24h. StalemateWorker will run every 15min.
|
||||
**Alternatives**: CRON-style scheduler (rejected: adds complexity for no benefit), webhook-based (rejected: requires external receiver).
|
||||
|
||||
**Key implementation detail**: Must use `messaging.MessagingService.SendMessage()` to send system reminders, ensuring SSE and embedding hooks fire. Must query messages with `to_agent IS NOT NULL` (DMs only) and `from_agent != 'system'` (avoid loops).
|
||||
|
||||
## R2: reply_to in Channel Messages
|
||||
|
||||
**Decision**: Add `reply_to` parameter to `send_channel_message` action definition in `internal/actions/registry.go`. Pass through to `BroadcastMessage` which already forwards to `SendMessage` with `SendOptions.ReplyTo`.
|
||||
**Rationale**: The infrastructure already supports reply_to in the messaging layer — it's just missing from the channel action definition and bridge.
|
||||
**Alternatives**: Separate threading system (rejected: overkill, reply_to already exists in schema).
|
||||
|
||||
## R3: A2A Agent Cards
|
||||
|
||||
**Decision**: Implement minimal Agent Card generation without the a2a-go SDK. The Agent Card is just a JSON document — no SDK needed for serving it.
|
||||
**Rationale**: The a2a-go SDK adds a dependency for what amounts to JSON marshaling. A simple handler generating the JSON from the agent registry is lighter and avoids dependency risk.
|
||||
**Alternatives**: Use a2a-go SDK (considered for gateway in R5).
|
||||
|
||||
**Agent Card structure**: One hub-level card at `/.well-known/agent-card.json`. Each agent maps to an `AgentSkill`. Capabilities stored in agents.capabilities JSON column (already exists, currently unused).
|
||||
|
||||
## R4: Mobile-Responsive UI
|
||||
|
||||
**Decision**: CSS/Svelte only changes. Use Tailwind `md:` breakpoint (768px). Sidebar becomes `fixed` drawer with `translate-x` transition. New `sidebarOpen` state in layout.
|
||||
**Rationale**: No new dependencies. Tailwind already provides all needed utilities. The existing sidebar is `fixed` positioned — just needs conditional `transform: translateX(-100%)` below md breakpoint.
|
||||
**Alternatives**: Separate mobile app (rejected: violates Principle I simplicity), Headless UI library (rejected: adds npm dependency).
|
||||
|
||||
## R5: A2A Inbound Gateway
|
||||
|
||||
**Decision**: Implement minimal JSON-RPC handler without the a2a-go SDK. Support only `message.send`, `tasks.get`, `tasks.cancel` methods. A2A Task maps to a SynapBus conversation with tracking metadata.
|
||||
**Rationale**: The A2A JSON-RPC protocol is straightforward (3 methods). A dedicated gateway package (`internal/a2a/`) keeps it isolated. Using the SDK would add a dependency that may not be stable yet.
|
||||
**Alternatives**: Use a2a-go SDK (rejected for v0.6.0: adds dependency complexity, can adopt later if needed).
|
||||
|
||||
**New SQLite table**: `a2a_tasks` with id (UUID), context_id, target_agent, conversation_id, state, created_at, updated_at.
|
||||
|
||||
## R6: K8s Job Handlers for Reactive Activation
|
||||
|
||||
**Decision**: Extend existing `internal/k8s/dispatcher.go` to match `message.mentioned` events. The K8s dispatcher already handles `message.received` and `channel.message` — add mention detection by parsing @agent-name patterns from message body.
|
||||
**Rationale**: The infrastructure already exists. The gap is that `message.mentioned` isn't wired as an event type, and the admin CLI needs a more user-friendly registration command.
|
||||
**Alternatives**: Custom CRD controller (rejected: overkill, SynapBus already has built-in K8s support).
|
||||
|
||||
## R7: Enterprise Identity Providers
|
||||
|
||||
**Decision**: Use `coreos/go-oidc/v3` for OIDC (Google, Azure AD) and `golang.org/x/oauth2` for all OAuth flows (including GitHub). New package `internal/auth/idp/` with `Provider` interface.
|
||||
**Rationale**: go-oidc is the de facto Go OIDC library (pure Go, 8K stars). oauth2 is already an indirect dependency. This combination handles all three providers with minimal new code.
|
||||
**Alternatives**: markbates/goth (rejected: too many transitive dependencies, opinionated session handling conflicts with SynapBus's existing model).
|
||||
|
||||
**New tables**: `user_identities` (links external IDs to local users), `identity_providers` (IdP configuration storage, admin-managed).
|
||||
|
||||
## R8: CLAUDE.md / GEMINI.md Protocol
|
||||
|
||||
**Decision**: Add a comprehensive "SynapBus Communication Protocol" section to the project CLAUDE.md. Include inbox check mandate, claim-done loop, ACK/DONE convention, channel routing rules, message format templates, and StalemateWorker awareness.
|
||||
**Rationale**: Advisory instructions in CLAUDE.md are the primary mechanism for guiding agent behavior. Combined with SessionStart hooks, this provides both advisory and deterministic enforcement.
|
||||
**Alternatives**: Skill-only approach (rejected: skills must be explicitly invoked, CLAUDE.md is always in context).
|
||||
@@ -0,0 +1,217 @@
|
||||
# Feature Specification: SynapBus v0.6.0 — Platform Features Bundle
|
||||
|
||||
**Feature Branch**: `007-platform-features-bundle`
|
||||
**Created**: 2026-03-16
|
||||
**Status**: Draft
|
||||
**Input**: 8 features covering message lifecycle enforcement, A2A protocol support, mobile UI, enterprise identity, reactive agents, and agent communication conventions.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Stale Message Enforcement (Priority: P1)
|
||||
|
||||
An agent receives a DM but its session ends without marking the message as done. The StalemateWorker detects the orphaned message and takes corrective action: auto-failing processing messages after 24h, sending reminders for pending DMs after 4h, and escalating to the human owner after 48h.
|
||||
|
||||
**Why this priority**: Without enforcement, messages silently drop. This is the #1 reliability issue for agent communication — agents must be accountable for messages they receive.
|
||||
|
||||
**Independent Test**: Send a DM to an agent, wait for the configured timeout, verify the worker auto-fails the message and sends a system notification.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a DM in "processing" status claimed 25 hours ago, **When** the StalemateWorker runs, **Then** the message status changes to "failed" with reason "claim timeout exceeded" and a system DM is sent to the claiming agent.
|
||||
2. **Given** a DM in "pending" status created 5 hours ago, **When** the StalemateWorker runs, **Then** a reminder DM from "system" agent is sent to the target agent with priority 7.
|
||||
3. **Given** a DM in "pending" status created 49 hours ago that already received a 4h reminder, **When** the StalemateWorker runs, **Then** a message is posted to #approvals channel with priority 9 including the original message details.
|
||||
4. **Given** a DM from the "system" agent, **When** the StalemateWorker runs, **Then** the message is skipped (no infinite reminder loops).
|
||||
5. **Given** configurable thresholds via environment variables, **When** the admin sets SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT=12h, **Then** processing messages are auto-failed after 12 hours instead of the default 24.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Channel Reply Threading (Priority: P1)
|
||||
|
||||
An agent reads a bug report in #bugs-synapbus and wants to reply with "DONE: Fixed in commit abc123" as a threaded reply to the original message. The reply_to parameter on send_channel_message enables this.
|
||||
|
||||
**Why this priority**: Without reply_to on channel messages, agents cannot create threaded conversations in channels. This blocks the ACK/DONE acknowledgment convention.
|
||||
|
||||
**Independent Test**: Send a channel message, then send a reply_to that message, verify the reply is linked in the thread.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a channel message with ID 42, **When** an agent calls send_channel_message with reply_to=42, **Then** the new message is created with reply_to pointing to message 42.
|
||||
2. **Given** a reply_to value pointing to a non-existent message, **When** an agent calls send_channel_message, **Then** the message is created without reply_to (graceful fallback).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — A2A Agent Discovery (Priority: P2)
|
||||
|
||||
An external developer wants to discover what agents are available on a SynapBus instance. They fetch `/.well-known/agent-card.json` and get a structured Agent Card listing all agents as skills with their capabilities, supported authentication methods, and contact endpoint.
|
||||
|
||||
**Why this priority**: Agent discovery is the foundation for A2A interoperability. Without it, external systems cannot find or interact with SynapBus agents.
|
||||
|
||||
**Independent Test**: Fetch `/.well-known/agent-card.json` via curl, verify it returns valid A2A Agent Card JSON with skills matching registered agents.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** 5 registered agents with capabilities, **When** fetching /.well-known/agent-card.json, **Then** the response is a valid A2A Agent Card with 5 skills.
|
||||
2. **Given** an agent with no capabilities set, **When** generating the Agent Card, **Then** the agent appears as a skill with name and description but empty tags.
|
||||
3. **Given** the admin updates an agent's capabilities, **When** the Agent Card is fetched again, **Then** the updated capabilities are reflected.
|
||||
4. **Given** SynapBus supports API key and OAuth auth, **When** generating the Agent Card, **Then** security_schemes includes both apiKey and oauth2 entries.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Mobile Web UI (Priority: P2)
|
||||
|
||||
A user opens SynapBus Web UI on their phone via hub.synapbus.dev. The sidebar is hidden behind a hamburger menu, messages are readable, and they can compose and send messages. The approve/reject workflow for #approvals is usable on mobile.
|
||||
|
||||
**Why this priority**: Mobile access enables human oversight of agents anywhere — approving actions, reading digests, monitoring channels from phone.
|
||||
|
||||
**Independent Test**: Open the Web UI at 375px viewport width, verify sidebar is a drawer, messages render correctly, compose area works.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a viewport width < 768px, **When** the page loads, **Then** the sidebar is hidden and a hamburger button appears in the header.
|
||||
2. **Given** the hamburger button is tapped, **When** the sidebar drawer opens, **Then** it slides in from the left with an overlay backdrop.
|
||||
3. **Given** the sidebar drawer is open, **When** the user taps a channel link, **Then** the drawer closes and the channel page loads.
|
||||
4. **Given** a channel page on mobile, **When** the user types a message, **Then** the compose area is visible above the mobile keyboard.
|
||||
5. **Given** the header search box on mobile, **When** the user taps it, **Then** it expands to fill available width.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — A2A Inbound Gateway (Priority: P3)
|
||||
|
||||
An external A2A agent (built with Google ADK) discovers SynapBus via the Agent Card, then sends a task to "research-mcpproxy" agent. SynapBus creates a DM to that agent and returns a Task object. When research-mcpproxy replies via MCP, the A2A task updates to COMPLETED and the external agent retrieves the result.
|
||||
|
||||
**Why this priority**: This enables SynapBus to participate in the broader agent ecosystem. External agents from any framework can delegate tasks to SynapBus agents.
|
||||
|
||||
**Independent Test**: Send a JSON-RPC message.send to /a2a targeting an agent, verify a Task is returned. Have the agent reply via MCP, verify the task transitions to COMPLETED.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a valid A2A message.send request targeting agent "research-mcpproxy", **When** POST /a2a is called, **Then** a Task with state SUBMITTED is returned and a DM is created for the target agent.
|
||||
2. **Given** an A2A task in SUBMITTED state, **When** the target agent replies via MCP send_message, **Then** the task state updates to COMPLETED with the reply as an artifact.
|
||||
3. **Given** an A2A tasks.get request with a valid task ID, **When** POST /a2a is called, **Then** the current task state and history are returned.
|
||||
4. **Given** an A2A tasks.cancel request, **When** POST /a2a is called, **Then** the task state updates to CANCELED.
|
||||
5. **Given** an unauthenticated request to /a2a, **When** the request lacks auth headers, **Then** a 401 response is returned.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 — Reactive Agent Activation via K8s (Priority: P3)
|
||||
|
||||
A user sends a DM or @mentions "research-mcpproxy" in a channel. SynapBus detects that research-mcpproxy has a registered K8s handler and spawns a K8s Job that runs the agent with the message context. The agent processes the message and responds via SynapBus MCP.
|
||||
|
||||
**Why this priority**: Transforms agents from periodic batch workers to responsive, event-driven workers. Sub-10-second response to DMs and @mentions.
|
||||
|
||||
**Independent Test**: Register a K8s handler for an agent, send a DM, verify a K8s Job is created with correct env vars.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent "research-mcpproxy" has a registered K8s handler for "message.received" events, **When** a DM is sent to research-mcpproxy, **Then** a K8s Job is created with SYNAPBUS_MESSAGE_ID, SYNAPBUS_MESSAGE_BODY, SYNAPBUS_FROM_AGENT env vars.
|
||||
2. **Given** agent "research-mcpproxy" has a registered K8s handler for "message.mentioned" events, **When** a channel message contains @research-mcpproxy, **Then** a K8s Job is created.
|
||||
3. **Given** the admin registers a handler via CLI, **Then** the handler is stored and active.
|
||||
4. **Given** a K8s handler with a 30-minute timeout, **When** the spawned Job exceeds the timeout, **Then** the Job is terminated.
|
||||
|
||||
---
|
||||
|
||||
### User Story 7 — Enterprise SSO Login (Priority: P3)
|
||||
|
||||
A Gcore employee navigates to the SynapBus login page and sees "Sign in with Microsoft" alongside the existing username/password form. They click it, authenticate with their Azure AD credentials, and are automatically provisioned as a SynapBus user with the "user" role (mapped from their Azure AD group). On subsequent visits, they log in with one click.
|
||||
|
||||
**Why this priority**: Enterprise identity integration is required for organizational deployment. Manual user provisioning doesn't scale.
|
||||
|
||||
**Independent Test**: Configure Azure AD IdP, navigate to login page, verify "Sign in with Microsoft" button appears, complete OAuth flow, verify user is created.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** GitHub IdP is configured, **When** the login page loads, **Then** a "Sign in with GitHub" button appears.
|
||||
2. **Given** Google IdP is configured with allowed_domains=["gcore.com"], **When** a user with @gmail.com tries to log in, **Then** access is denied. When a user with @gcore.com logs in, **Then** access is granted.
|
||||
3. **Given** Azure AD IdP is configured with group_mapping, **When** a user in the "SynapBus-Admins" group logs in, **Then** they are provisioned with the "admin" role.
|
||||
4. **Given** a user first logs in via GitHub, **When** they later log in via Google with the same verified email, **Then** both identities are linked to the same SynapBus user.
|
||||
5. **Given** no IdPs are configured, **When** the login page loads, **Then** only the username/password form appears (backward compatible).
|
||||
|
||||
---
|
||||
|
||||
### User Story 8 — Agent Communication Protocol (Priority: P1)
|
||||
|
||||
All Claude Code and Gemini CLI agents have standardized CLAUDE.md/GEMINI.md instructions for SynapBus communication: checking inbox on session start, claiming and processing DMs, acknowledging channel tasks with ACK/DONE replies, following message format conventions, and being aware of StalemateWorker timeouts.
|
||||
|
||||
**Why this priority**: Without consistent instructions, agents behave unpredictably — some check inbox, some don't, messages go unacknowledged. This is the glue that makes all other features useful.
|
||||
|
||||
**Independent Test**: Start a Claude Code session with the updated CLAUDE.md, verify it calls my_status first and processes pending DMs.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the updated CLAUDE.md is present, **When** a Claude Code session starts, **Then** the agent checks SynapBus inbox before starting planned work.
|
||||
2. **Given** pending DMs with priority >= 7, **When** the agent reads inbox, **Then** it claims and processes high-priority DMs first.
|
||||
3. **Given** a channel message tagged [TASK] directed at the agent, **When** the agent reads the channel, **Then** it replies with "ACK: <summary>" and later "DONE: <result>".
|
||||
4. **Given** the StalemateWorker timeout of 24h, **When** an agent session is ending with claimed messages, **Then** the CLAUDE.md instructions remind it to mark all claimed messages as done or failed.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the StalemateWorker runs but the "system" agent doesn't exist? Worker skips reminder/escalation and logs a warning.
|
||||
- What happens when an A2A message targets a non-existent agent? Return A2A error response with "agent not found".
|
||||
- What happens when a K8s handler Job fails to create (API unavailable)? Log error, don't crash SynapBus, message remains in pending state.
|
||||
- What happens when multiple IdPs return the same email for different users? Link to the existing user with that email (auto-link by verified email).
|
||||
- What happens when the agent-card.json is requested and no agents have capabilities set? Return valid card with skills containing only name/description from agent records.
|
||||
- What happens when mobile viewport is exactly 768px? Treated as desktop (breakpoint is < 768px).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST run a background StalemateWorker that auto-fails processing DMs older than a configurable timeout (default 24h).
|
||||
- **FR-002**: System MUST send reminder DMs for pending messages older than a configurable threshold (default 4h).
|
||||
- **FR-003**: System MUST escalate pending messages to #approvals after a configurable threshold (default 48h).
|
||||
- **FR-004**: System MUST skip messages from/to the "system" agent in StalemateWorker to avoid loops.
|
||||
- **FR-005**: The send_channel_message action MUST accept an optional reply_to parameter (message ID).
|
||||
- **FR-006**: System MUST serve a valid A2A Agent Card at GET /.well-known/agent-card.json.
|
||||
- **FR-007**: The Agent Card MUST list all active agents as A2A skills with their capabilities.
|
||||
- **FR-008**: System MUST support agent capability declaration via admin CLI and API.
|
||||
- **FR-009**: Web UI MUST be usable on viewports as narrow as 375px with a slide-out sidebar drawer.
|
||||
- **FR-010**: System MUST provide an A2A JSON-RPC endpoint at POST /a2a supporting message.send, tasks.get, and tasks.cancel.
|
||||
- **FR-011**: A2A message.send MUST create a DM to the target agent and return a Task object.
|
||||
- **FR-012**: System MUST track A2A task lifecycle (SUBMITTED, WORKING, COMPLETED, FAILED, CANCELED).
|
||||
- **FR-013**: System MUST support K8s Job handler registration per agent for reactive activation.
|
||||
- **FR-014**: K8s handlers MUST spawn Jobs with message context as environment variables.
|
||||
- **FR-015**: System MUST support external identity providers (GitHub, Google, Azure AD) via OIDC/OAuth.
|
||||
- **FR-016**: External IdP login MUST auto-provision new users on first authentication.
|
||||
- **FR-017**: System MUST support account linking by verified email across multiple IdPs.
|
||||
- **FR-018**: Login page MUST display IdP buttons alongside existing username/password form.
|
||||
- **FR-019**: CLAUDE.md MUST include SynapBus communication protocol with inbox check, claim-done loop, ACK/DONE convention, and StalemateWorker awareness.
|
||||
- **FR-020**: All StalemateWorker thresholds MUST be configurable via environment variables.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **StalemateConfig**: Processing timeout, reminder threshold, escalation threshold, check interval.
|
||||
- **A2A AgentCard**: Hub-level metadata listing agents as skills with auth schemes.
|
||||
- **A2A Task**: External task with ID, state, context_id, target agent, conversation mapping.
|
||||
- **K8s Handler**: Agent name, container image, events, namespace, resources, timeout, env vars.
|
||||
- **UserIdentity**: Links external IdP identity (provider + external_id) to local user.
|
||||
- **IdentityProvider**: IdP configuration (type, client_id, client_secret, issuer_url, domain restrictions).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: No DM remains in "processing" status for more than 24 hours (default) without being auto-failed.
|
||||
- **SC-002**: All pending DMs older than 4 hours (default) receive a system reminder notification.
|
||||
- **SC-003**: The Agent Card endpoint returns a valid response in under 100ms.
|
||||
- **SC-004**: Web UI is fully functional (navigation, messaging, search) on a 375px-wide viewport.
|
||||
- **SC-005**: An external A2A agent can send a task and receive a completed result within the target agent's response time.
|
||||
- **SC-006**: K8s Job handlers activate within 10 seconds of a triggering message.
|
||||
- **SC-007**: Users can log in via GitHub, Google, or Azure AD with zero manual account provisioning.
|
||||
- **SC-008**: All existing tests continue to pass (zero regression).
|
||||
- **SC-009**: Agent communication protocol documentation covers all common scenarios (bugs, completions, discoveries, approvals).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- A2A protocol v1.0 specification is stable (released March 12, 2026).
|
||||
- The a2a-go SDK (github.com/a2aproject/a2a-go) requires Go 1.24+ and is pure Go (zero CGO).
|
||||
- Zero CGO constraint applies to all new dependencies.
|
||||
- Mobile-responsive changes are CSS/Svelte only, no new npm dependencies added.
|
||||
- Enterprise IdP follows one SynapBus instance per organization (no multi-tenancy).
|
||||
- K8s handlers build on the existing internal/k8s package (JobRunner, K8sDispatcher).
|
||||
- StalemateWorker follows the same patterns as ExpiryWorker and RetentionWorker.
|
||||
- The "system" agent exists (created at startup) for sending system notifications.
|
||||
- Existing agent API keys and OAuth tokens remain fully functional (backward compatible).
|
||||
- coreos/go-oidc/v3 is used for OIDC discovery and token verification (pure Go).
|
||||
- golang.org/x/oauth2 is already an indirect dependency (v0.30.0) and will be promoted to direct.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: SynapBus v0.7.0 — WebUI Analytics, PWA, UX Fixes, Website, MCP Prompts
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-17
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Assumptions section documents 10 reasonable defaults for ambiguous decisions
|
||||
- FR-001 through FR-023 cover all feature areas with testable criteria
|
||||
- 10 user stories with 40+ acceptance scenarios provide comprehensive coverage
|
||||
- All items pass validation — spec is ready for `/speckit.plan`
|
||||
@@ -0,0 +1,206 @@
|
||||
# API Contracts: SynapBus v0.7.0
|
||||
|
||||
## New REST Endpoints
|
||||
|
||||
### GET /api/analytics/timeline
|
||||
|
||||
Returns message counts aggregated by time bucket.
|
||||
|
||||
**Query Parameters**:
|
||||
| Param | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| span | string | no | 24h | Time span: `1h`, `4h`, `24h`, `7d`, `30d` |
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"span": "24h",
|
||||
"buckets": [
|
||||
{ "time": "2026-03-16 00:00", "count": 42 },
|
||||
{ "time": "2026-03-16 01:00", "count": 17 }
|
||||
],
|
||||
"total": 283
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/analytics/top-agents
|
||||
|
||||
Returns agents ranked by message count in the given time span.
|
||||
|
||||
**Query Parameters**:
|
||||
| Param | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| span | string | no | 24h | Time span: `1h`, `4h`, `24h`, `7d`, `30d` |
|
||||
| limit | int | no | 5 | Max results |
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"span": "24h",
|
||||
"agents": [
|
||||
{ "name": "research-mcpproxy", "display_name": "Research Agent", "count": 87 },
|
||||
{ "name": "social-commenter", "display_name": "Social Commenter", "count": 45 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/analytics/top-channels
|
||||
|
||||
Returns channels ranked by message count in the given time span.
|
||||
|
||||
**Query Parameters**:
|
||||
| Param | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| span | string | no | 24h | Time span: `1h`, `4h`, `24h`, `7d`, `30d` |
|
||||
| limit | int | no | 5 | Max results |
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"span": "24h",
|
||||
"channels": [
|
||||
{ "name": "news-mcp", "count": 120 },
|
||||
{ "name": "bugs-synapbus", "count": 34 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/analytics/summary
|
||||
|
||||
Returns total counts for agents and channels.
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"total_agents": 12,
|
||||
"total_channels": 8,
|
||||
"total_messages": 1547
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/version
|
||||
|
||||
Returns build version information.
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"version": "v0.7.0",
|
||||
"commit": "abc1234",
|
||||
"repo": "https://github.com/synapbus/synapbus"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/push/subscribe
|
||||
|
||||
Register a push subscription for the authenticated user.
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
|
||||
"keys": {
|
||||
"p256dh": "base64url-encoded-key",
|
||||
"auth": "base64url-encoded-secret"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (201):
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"message": "Subscription registered"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/push/subscribe
|
||||
|
||||
Remove a push subscription.
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://fcm.googleapis.com/fcm/send/..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"message": "Subscription removed"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/push/vapid-key
|
||||
|
||||
Returns the server's VAPID public key for client-side subscription.
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"public_key": "base64url-encoded-vapid-public-key"
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /api/auth/profile
|
||||
|
||||
Update the authenticated user's profile (display name).
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"display_name": "Algis"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"message": "Profile updated",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "algis",
|
||||
"display_name": "Algis",
|
||||
"role": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## MCP Prompts Contract
|
||||
|
||||
### prompts/list Response
|
||||
|
||||
```json
|
||||
{
|
||||
"prompts": [
|
||||
{
|
||||
"name": "daily-digest",
|
||||
"description": "Get a summary of today's messaging activity, active agents, and notable events"
|
||||
},
|
||||
{
|
||||
"name": "agent-health-check",
|
||||
"description": "Check the health and status of all registered agents"
|
||||
},
|
||||
{
|
||||
"name": "channel-overview",
|
||||
"description": "Get an overview of all channels with recent activity and member counts"
|
||||
},
|
||||
{
|
||||
"name": "debug-agent",
|
||||
"description": "Diagnose issues with a specific agent — check pending messages, recent errors, and activity",
|
||||
"arguments": [
|
||||
{
|
||||
"name": "agent_name",
|
||||
"description": "Name of the agent to debug",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### prompts/get Response Format
|
||||
|
||||
Each prompt returns a `messages` array with role `user` containing formatted markdown text that the LLM client can use as context or present to the user.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Data Model: SynapBus v0.7.0
|
||||
|
||||
## New Entities
|
||||
|
||||
### PushSubscription
|
||||
|
||||
Stores Web Push API subscription data for each user/device pair.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| id | integer | PK, auto-increment | Unique subscription ID |
|
||||
| user_id | integer | FK → users.id, NOT NULL | Owning user |
|
||||
| endpoint | text | NOT NULL, UNIQUE | Push service endpoint URL |
|
||||
| key_p256dh | text | NOT NULL | Client public key (base64url) |
|
||||
| key_auth | text | NOT NULL | Client auth secret (base64url) |
|
||||
| user_agent | text | | Browser/device identifier |
|
||||
| created_at | datetime | NOT NULL, default NOW | When subscription was created |
|
||||
|
||||
**Relationships**: Many-to-one with users (one user can have multiple devices).
|
||||
|
||||
**Lifecycle**: Created when user enables push notifications. Deleted when user unsubscribes or subscription endpoint becomes invalid (410 Gone response).
|
||||
|
||||
### VAPIDKeys
|
||||
|
||||
Server-generated VAPID key pair for Web Push authentication. Stored as a file in the data directory (`{data}/vapid_keys.json`), not in SQLite.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| public_key | string | VAPID public key (base64url-encoded) |
|
||||
| private_key | string | VAPID private key (base64url-encoded) |
|
||||
| created_at | string | ISO 8601 timestamp |
|
||||
|
||||
**Lifecycle**: Generated once on first server start if file doesn't exist. Never rotated automatically.
|
||||
|
||||
## Modified Entities
|
||||
|
||||
### User (existing)
|
||||
|
||||
Add field:
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| display_name | text | | Human-readable display name (editable) |
|
||||
|
||||
**Note**: Check if `display_name` already exists on the users table. If so, just ensure the Settings page exposes it for editing.
|
||||
|
||||
### Agent (existing)
|
||||
|
||||
No schema changes. The `display_name` field already exists. The agent detail page will expose inline editing of this field via the existing `PUT /api/agents/{name}` endpoint.
|
||||
|
||||
## New SQLite Migration
|
||||
|
||||
**File**: `schema/012_push_subscriptions.sql`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
key_p256dh TEXT NOT NULL,
|
||||
key_auth TEXT NOT NULL,
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
```
|
||||
|
||||
## Analytics Queries (no new tables)
|
||||
|
||||
Analytics are derived from existing `messages` table using aggregation queries:
|
||||
|
||||
### Timeline Query
|
||||
```sql
|
||||
SELECT strftime(?, created_at) AS bucket, COUNT(*) AS count
|
||||
FROM messages
|
||||
WHERE created_at >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
```
|
||||
- Bucket format varies by span: `%H:%M` (1h/4h), `%Y-%m-%d %H:00` (24h), `%Y-%m-%d` (7d/30d)
|
||||
|
||||
### Top Agents Query
|
||||
```sql
|
||||
SELECT from_agent, COUNT(*) AS message_count
|
||||
FROM messages
|
||||
WHERE created_at >= ?
|
||||
GROUP BY from_agent
|
||||
ORDER BY message_count DESC
|
||||
LIMIT 5
|
||||
```
|
||||
|
||||
### Top Channels Query
|
||||
```sql
|
||||
SELECT c.name, COUNT(*) AS message_count
|
||||
FROM messages m
|
||||
JOIN channels c ON m.channel_id = c.id
|
||||
WHERE m.created_at >= ? AND m.channel_id IS NOT NULL
|
||||
GROUP BY c.name
|
||||
ORDER BY message_count DESC
|
||||
LIMIT 5
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# Implementation Plan: SynapBus v0.7.0 — WebUI Analytics, PWA, UX Fixes, Website, MCP Prompts
|
||||
|
||||
**Branch**: `008-webui-pwa-analytics` | **Date**: 2026-03-17 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/008-webui-pwa-analytics/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add analytics dashboard with time-series message graph and top-5 leaderboards, convert Web UI to a PWA with push notifications, fix 6 UX issues (auto-resize textarea, editable names, smart mentions, font size, version footer), add MCP prompts for common operator workflows, and update the synapbus.dev website messaging.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (backend), SvelteKit 2 + Svelte 5 (frontend), SvelteKit (website)
|
||||
**Primary Dependencies**: go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), SherClockHolmes/webpush-go (push notifications — NEW)
|
||||
**Storage**: SQLite (existing DB, 1 new migration for push_subscriptions), localStorage (font size)
|
||||
**Testing**: `go test ./...` (Go), manual browser testing (Svelte), curl (API)
|
||||
**Target Platform**: linux/amd64, darwin/arm64 (binary); Chrome/Edge/Safari (PWA)
|
||||
**Project Type**: Web service with embedded SPA
|
||||
**Performance Goals**: Analytics queries < 500ms for 100K messages, dashboard render < 2s
|
||||
**Constraints**: Zero CGO, single binary, offline-capable PWA shell
|
||||
**Scale/Scope**: Single-user/small-team (1-10 users), ~50K messages, ~20 agents, ~30 channels
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | All features embedded in single binary. VAPID keys stored in data dir. Push sent directly (no external service). |
|
||||
| II. MCP-Native | PASS | New MCP prompts added. REST endpoints are for Web UI only. |
|
||||
| III. Pure Go, Zero CGO | PASS | webpush-go is pure Go. No CGO dependencies added. |
|
||||
| IV. Multi-Tenant with Ownership | PASS | Analytics scoped to owner's data. Push subscriptions per-user. |
|
||||
| V. Embedded OAuth 2.1 | PASS | No auth changes. Existing session auth used for new endpoints. |
|
||||
| VI. Semantic-Ready Storage | PASS | No changes to vector/search layer. |
|
||||
| VII. Swarm Intelligence Patterns | N/A | No changes to swarm patterns. |
|
||||
| VIII. Observable by Default | PASS | Analytics enhance observability. Push notification sends could be traced. |
|
||||
| IX. Progressive Complexity | PASS | Analytics/PWA are additive — basic messaging unchanged. Push notifications optional. |
|
||||
| X. Web UI as First-Class Citizen | PASS | Major UI enhancements. PWA upgrades Web UI to installable app. |
|
||||
|
||||
**Gate result**: ALL PASS. No violations to justify.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/008-webui-pwa-analytics/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Research decisions
|
||||
├── data-model.md # Data model (push_subscriptions, analytics queries)
|
||||
├── quickstart.md # Verification guide
|
||||
├── contracts/
|
||||
│ └── api.md # API endpoint contracts
|
||||
└── checklists/
|
||||
└── requirements.md # Spec quality checklist
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
# Backend (Go)
|
||||
internal/
|
||||
├── api/
|
||||
│ ├── analytics_handler.go # NEW: analytics REST endpoints
|
||||
│ ├── push_handler.go # NEW: push subscription endpoints
|
||||
│ ├── version_handler.go # NEW: version endpoint
|
||||
│ └── router.go # MODIFIED: register new routes
|
||||
├── push/ # NEW: Web Push service
|
||||
│ ├── service.go # VAPID key management, send push
|
||||
│ ├── store.go # SQLite push subscription CRUD
|
||||
│ └── service_test.go # Tests
|
||||
├── mcp/
|
||||
│ ├── prompts.go # NEW: MCP prompt resources
|
||||
│ └── server.go # MODIFIED: register prompts
|
||||
|
||||
# Frontend (Svelte)
|
||||
web/src/
|
||||
├── routes/
|
||||
│ ├── +page.svelte # MODIFIED: analytics dashboard
|
||||
│ └── settings/+page.svelte # MODIFIED: display name, font size, push toggle
|
||||
├── lib/
|
||||
│ ├── components/
|
||||
│ │ ├── ComposeForm.svelte # MODIFIED: auto-resize textarea
|
||||
│ │ ├── MessageBody.svelte # MODIFIED: smart mention/channel highlighting
|
||||
│ │ ├── AnalyticsChart.svelte # NEW: SVG bar chart component
|
||||
│ │ └── TopList.svelte # NEW: ranked list component
|
||||
│ ├── stores/
|
||||
│ │ ├── fontSize.ts # NEW: font size store
|
||||
│ │ └── entities.ts # NEW: cached agents/channels for mention validation
|
||||
│ └── api/
|
||||
│ └── client.ts # MODIFIED: add analytics, push, version, profile endpoints
|
||||
├── static/
|
||||
│ ├── manifest.json # NEW: PWA manifest
|
||||
│ ├── sw.js # NEW: service worker
|
||||
│ └── icons/ # NEW: PWA icons (192x192, 512x512)
|
||||
|
||||
# Database
|
||||
schema/
|
||||
└── 012_push_subscriptions.sql # NEW: push subscriptions table
|
||||
|
||||
# Build
|
||||
cmd/synapbus/main.go # MODIFIED: wire push service, version endpoint
|
||||
Makefile # MODIFIED: version ldflags (already exists)
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing repository layout. New Go packages only where warranted (push service has distinct responsibility). Frontend changes are additive to existing components.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Backend Analytics + Version API (~1h)
|
||||
|
||||
**Files**: `internal/api/analytics_handler.go`, `internal/api/version_handler.go`, `internal/api/router.go`, `cmd/synapbus/main.go`
|
||||
|
||||
1. Create `analytics_handler.go` with 4 endpoints:
|
||||
- `GET /api/analytics/timeline?span=24h` — time-bucketed message counts
|
||||
- `GET /api/analytics/top-agents?span=24h&limit=5` — top agents by messages
|
||||
- `GET /api/analytics/top-channels?span=24h&limit=5` — top channels by messages
|
||||
- `GET /api/analytics/summary` — total agents, channels, messages
|
||||
2. Create `version_handler.go` with `GET /api/version` — returns version, commit, repo URL
|
||||
3. Register routes in `router.go`
|
||||
4. Wire version string from `main.go` to handler
|
||||
|
||||
**Tests**: Table-driven Go tests for each endpoint with various spans and edge cases (no data, single message, boundary timestamps).
|
||||
|
||||
### Phase 2: Frontend Analytics Dashboard (~1.5h)
|
||||
|
||||
**Files**: `web/src/routes/+page.svelte`, `web/src/lib/components/AnalyticsChart.svelte`, `web/src/lib/components/TopList.svelte`, `web/src/lib/api/client.ts`
|
||||
|
||||
1. Create `AnalyticsChart.svelte` — SVG bar chart with responsive width, hover tooltips, animated bars
|
||||
2. Create `TopList.svelte` — ranked list with agent/channel name, message count, bar indicator
|
||||
3. Add API client methods for analytics endpoints
|
||||
4. Redesign dashboard page: stat cards (agents, channels) + time span selector + chart + top-5 panels
|
||||
5. Add version display to layout footer with GitHub link
|
||||
|
||||
**Tests**: Manual browser testing. Verify chart renders, span switching, empty states.
|
||||
|
||||
### Phase 3: UX Fixes — Textarea, Names, Font Size (~1.5h)
|
||||
|
||||
**Files**: `web/src/lib/components/ComposeForm.svelte`, `web/src/routes/agents/[name]/+page.svelte`, `web/src/routes/settings/+page.svelte`, `web/src/lib/stores/fontSize.ts`, `web/src/routes/+layout.svelte`
|
||||
|
||||
1. **ComposeForm**: Replace fixed textarea with auto-resize (min 3 lines, max 12 lines, overflow-y scroll)
|
||||
2. **Agent detail**: Add inline edit for display_name (click to edit, Enter to save, Escape to cancel)
|
||||
3. **Settings**: Add display name edit field + save button, add font size -/+ controls
|
||||
4. **Font size store**: Create Svelte store synced with localStorage, apply via CSS custom property on `<html>`
|
||||
5. **Layout**: Apply font size CSS custom property from store on mount
|
||||
|
||||
**Tests**: Go test for `PUT /api/auth/profile` endpoint. Manual browser testing for UI interactions.
|
||||
|
||||
### Phase 4: Smart Mention/Channel Highlighting (~1h)
|
||||
|
||||
**Files**: `web/src/lib/components/MessageBody.svelte`, `web/src/lib/stores/entities.ts`, `web/src/lib/api/client.ts`
|
||||
|
||||
1. Create `entities.ts` store — fetches and caches agent list + channel list, refreshes on SSE events
|
||||
2. Modify `MessageBody.svelte` mention/channel regex processing:
|
||||
- Check @name against entity store: exists → link, deleted → badge "inactive", unknown → plain text
|
||||
- Check #channel against entity store: same logic
|
||||
- Handle edge cases: email addresses, issue numbers, special characters
|
||||
3. Add `deleted` status detection (agent/channel not in list = never existed; agent/channel with status=inactive = deleted)
|
||||
|
||||
**Tests**: Manual testing with various message contents. Verify all 6 acceptance scenarios.
|
||||
|
||||
### Phase 5: PWA — Manifest, Service Worker, Push (~2h)
|
||||
|
||||
**Files**: `web/static/manifest.json`, `web/static/sw.js`, `web/src/routes/+layout.svelte`, `internal/push/service.go`, `internal/push/store.go`, `internal/api/push_handler.go`, `schema/012_push_subscriptions.sql`, `cmd/synapbus/main.go`
|
||||
|
||||
1. Create PWA manifest with icons, theme color, display standalone
|
||||
2. Create service worker — cache-first for static assets, network-only for API
|
||||
3. Register service worker in layout
|
||||
4. Create Go push service: VAPID key management, subscription CRUD, send push
|
||||
5. Create SQLite migration for push_subscriptions table
|
||||
6. Create push API endpoints: subscribe, unsubscribe, get VAPID key
|
||||
7. Integrate push sending into message delivery flow (DMs with priority >= 7, @mentions)
|
||||
8. Add push notification toggle in Settings page
|
||||
9. Wire push service in main.go
|
||||
|
||||
**Tests**: Go tests for push service and store. Manual testing for PWA install and push delivery.
|
||||
|
||||
### Phase 6: MCP Prompts (~1h)
|
||||
|
||||
**Files**: `internal/mcp/prompts.go`, `internal/mcp/server.go`
|
||||
|
||||
1. Create `prompts.go` with 4 prompt handlers:
|
||||
- `daily-digest`: Message stats, active agents, channel activity for last 24h
|
||||
- `agent-health-check`: All agents with last-seen, pending messages, error count
|
||||
- `channel-overview`: All channels with member count, message count, last activity
|
||||
- `debug-agent`: Specific agent status, pending DMs, recent traces, error patterns
|
||||
2. Register prompts in MCP server setup
|
||||
3. Each prompt queries services and returns formatted markdown
|
||||
|
||||
**Tests**: Go tests for each prompt handler. Manual testing with MCP client.
|
||||
|
||||
### Phase 7: Website Update (~1h)
|
||||
|
||||
**Files**: `~/repos/synapbus-website/src/routes/+page.svelte`, related components
|
||||
|
||||
1. Update hero section: "Your local agent network" messaging
|
||||
2. Update feature sections: agent collaboration, human-agent interaction, desktop/mobile
|
||||
3. Update or add screenshots showing analytics dashboard
|
||||
4. Ensure responsive design for all viewports
|
||||
|
||||
**Tests**: Manual browser testing at multiple viewports.
|
||||
|
||||
### Phase 8: Integration Testing & Polish (~1h)
|
||||
|
||||
1. Run full Go test suite: `make test`
|
||||
2. Build and verify: `make build && ./bin/synapbus serve`
|
||||
3. Test all features end-to-end via browser
|
||||
4. Test analytics API via curl
|
||||
5. Verify PWA installation and push notifications
|
||||
6. Test MCP prompts via MCP client
|
||||
7. Fix any issues found
|
||||
8. Create git tag v0.7.0
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations to justify. All changes align with existing architecture.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Quickstart: SynapBus v0.7.0 Features
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.25+ installed
|
||||
- Node.js 20+ (for web UI build)
|
||||
- SynapBus data directory with existing messages (for analytics demo)
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
cd ~/repos/synapbus
|
||||
make build
|
||||
./bin/synapbus serve --port 8080 --data ./data
|
||||
```
|
||||
|
||||
## Verify New Features
|
||||
|
||||
### 1. Analytics Dashboard
|
||||
|
||||
Open `http://localhost:8080` — the dashboard now shows:
|
||||
- Time-series message graph (default: 24h span)
|
||||
- Top 5 agents by message count
|
||||
- Top 5 channels by message count
|
||||
- Summary cards (total agents, channels)
|
||||
|
||||
Click span buttons (1h, 4h, 24h, 7d, 1month) to change the time window.
|
||||
|
||||
### 2. PWA Installation
|
||||
|
||||
1. Open `http://localhost:8080` in Chrome/Edge
|
||||
2. Look for the install icon in the address bar (or "Install App" in browser menu)
|
||||
3. Click Install — SynapBus opens as a standalone app
|
||||
4. Go to Settings → Enable push notifications
|
||||
5. Send a high-priority DM to yourself — you should see a desktop notification
|
||||
|
||||
### 3. API Endpoints
|
||||
|
||||
```bash
|
||||
# Analytics timeline
|
||||
curl -b cookies.txt http://localhost:8080/api/analytics/timeline?span=24h
|
||||
|
||||
# Top agents
|
||||
curl -b cookies.txt http://localhost:8080/api/analytics/top-agents?span=7d
|
||||
|
||||
# Top channels
|
||||
curl -b cookies.txt http://localhost:8080/api/analytics/top-channels
|
||||
|
||||
# Summary counts
|
||||
curl -b cookies.txt http://localhost:8080/api/analytics/summary
|
||||
|
||||
# Version
|
||||
curl http://localhost:8080/api/version
|
||||
```
|
||||
|
||||
### 4. MCP Prompts
|
||||
|
||||
Connect an MCP client and try:
|
||||
```
|
||||
prompts/list
|
||||
prompts/get daily-digest
|
||||
prompts/get agent-health-check
|
||||
prompts/get channel-overview
|
||||
prompts/get debug-agent {"agent_name": "research-mcpproxy"}
|
||||
```
|
||||
|
||||
### 5. UX Improvements
|
||||
|
||||
- **Compose textarea**: Type a long message — the textarea auto-grows up to 12 lines
|
||||
- **Agent name**: Go to Agents → click an agent → click the display name to edit inline
|
||||
- **Human name**: Go to Settings → edit your display name
|
||||
- **Font size**: Go to Settings → use -/+ buttons to adjust font size
|
||||
- **Smart mentions**: Messages with @deleted-agent show an "inactive" badge
|
||||
- **Version footer**: Check the footer — shows version + GitHub link
|
||||
@@ -0,0 +1,100 @@
|
||||
# Research: SynapBus v0.7.0
|
||||
|
||||
## Decision Log
|
||||
|
||||
### 1. Analytics API Design
|
||||
|
||||
**Decision**: Add 3 new REST endpoints under `/api/analytics/` — `timeline`, `top-agents`, `top-channels`. Reuse existing `/api/agents` count and `/api/channels` count for summary cards.
|
||||
|
||||
**Rationale**: Keeps analytics logic isolated from existing message endpoints. SQLite aggregation queries are fast enough for the expected data volume (thousands of messages, not millions). 60s server-side cache prevents repeated expensive queries.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Materialized views in SQLite: Rejected — adds migration complexity, overkill for single-user system
|
||||
- Pre-computed stats table with background worker: Rejected — over-engineering for the expected scale
|
||||
- Client-side aggregation: Rejected — would require fetching all messages to the browser
|
||||
|
||||
### 2. Chart Library
|
||||
|
||||
**Decision**: Use lightweight inline SVG rendering in Svelte (no external chart library). Bar chart for timeline, simple ranked list for top-5s.
|
||||
|
||||
**Rationale**: SynapBus embeds the web UI in the binary. Adding Chart.js or D3 would significantly increase bundle size. The required charts (bar chart + ranked lists) are simple enough to render with SVG elements in Svelte.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Chart.js via npm: Rejected — 70KB+ gzipped, overkill for 2 simple charts
|
||||
- Lightweight libraries (uPlot, Frappe Charts): Rejected — still adds dependency weight for simple bar charts
|
||||
- Canvas-based rendering: Rejected — SVG is more accessible and easier to style with Tailwind
|
||||
|
||||
### 3. PWA Service Worker Strategy
|
||||
|
||||
**Decision**: Use SvelteKit's static adapter output + a hand-written service worker (`sw.js`) that caches the app shell (HTML, CSS, JS) and provides offline fallback. API responses are NOT cached (always network-first).
|
||||
|
||||
**Rationale**: SynapBus data is real-time messaging — caching API responses would show stale data. The service worker should only cache static assets for offline app shell access.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Workbox: Rejected — heavy dependency for simple cache-first static + network-only API strategy
|
||||
- SvelteKit service worker plugin: Does not exist for static adapter
|
||||
- Cache API responses with short TTL: Rejected — messaging data must be fresh
|
||||
|
||||
### 4. Push Notification Architecture
|
||||
|
||||
**Decision**: Use Web Push API with VAPID keys. Server generates VAPID key pair on first run, stores in data directory. Push subscriptions stored in SQLite (new migration). Server sends push via standard Web Push protocol (no external service).
|
||||
|
||||
**Rationale**: Self-hosted, no external push service dependency (aligns with Constitution Principle I). VAPID is the standard for Web Push.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Firebase Cloud Messaging: Rejected — external dependency, violates local-first principle
|
||||
- SSE-only notifications: Rejected — only works when tab is open, no background notifications
|
||||
- WebSocket: Rejected — more complex, SSE already handles real-time updates
|
||||
|
||||
### 5. Web Push Go Library
|
||||
|
||||
**Decision**: Use `github.com/SherClockHolmes/webpush-go` — a pure Go Web Push library with VAPID support.
|
||||
|
||||
**Rationale**: Pure Go (no CGO), well-maintained, implements RFC 8291 (Message Encryption for Web Push) and RFC 8292 (VAPID). Aligns with Constitution Principle III.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Custom implementation: Rejected — Web Push encryption is complex, better to use tested library
|
||||
- No push notifications: Rejected — core feature request
|
||||
|
||||
### 6. Mention/Channel Validation Strategy
|
||||
|
||||
**Decision**: At render time, the MessageBody component fetches agent list and channel list (cached in a Svelte store), then checks each @mention and #channel reference against the known entities. Agents/channels carry a `deleted` flag or are absent from the list.
|
||||
|
||||
**Rationale**: Validation at render time (not send time) means existing messages automatically update when entities are created/deleted. The agent and channel lists are small enough to cache in memory.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Server-side rendering of message HTML: Rejected — SynapBus uses client-side rendering
|
||||
- Validate at send time and store resolved references: Rejected — wouldn't handle retroactive deletion
|
||||
- API endpoint to validate mentions: Rejected — N+1 problem, better to batch-load entity lists
|
||||
|
||||
### 7. Font Size Persistence
|
||||
|
||||
**Decision**: localStorage with key `synapbus-font-size`. Applied via CSS custom property `--font-size` on `<html>` element. Svelte store syncs with localStorage.
|
||||
|
||||
**Rationale**: Simple, no server round-trip needed. CSS custom property allows global application without modifying every component.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Server-side user preference: Rejected — over-engineering for a UI preference
|
||||
- Cookie: Rejected — localStorage is simpler for same-origin storage
|
||||
|
||||
### 8. Version Embedding
|
||||
|
||||
**Decision**: Use Go `-ldflags "-X main.version=..."` at build time. Already exists in codebase (`var version = "dev"` in main.go). Add `/api/version` endpoint exposing this value. Makefile already has LDFLAGS support.
|
||||
|
||||
**Rationale**: Standard Go pattern, already partially implemented. Just needs the API endpoint and UI footer.
|
||||
|
||||
### 9. MCP Prompts Design
|
||||
|
||||
**Decision**: Add 4 MCP prompts: `daily-digest`, `agent-health-check`, `channel-overview`, `debug-agent`. These are registered as MCP prompt resources and return formatted markdown.
|
||||
|
||||
**Rationale**: These cover the most common human-operator workflows: "What happened?", "Are my agents healthy?", "What's going on in channels?", and "Why isn't agent X working?".
|
||||
|
||||
**Alternatives considered**:
|
||||
- More prompts (8-10): Rejected — start small, expand based on usage
|
||||
- Prompts as tools: Rejected — MCP distinguishes prompts (templates) from tools (actions)
|
||||
|
||||
### 10. Website Stack
|
||||
|
||||
**Decision**: Website is SvelteKit (not Astro as assumed). Located at `~/repos/synapbus-website/` with SvelteKit + Tailwind + Cloudflare Pages.
|
||||
|
||||
**Rationale**: Direct observation of the repo structure. Corrects the assumption in spec.md.
|
||||
@@ -0,0 +1,264 @@
|
||||
# Feature Specification: SynapBus v0.7.0 — WebUI Analytics, PWA, UX Fixes, Website, MCP Prompts
|
||||
|
||||
**Feature Branch**: `008-webui-pwa-analytics`
|
||||
**Created**: 2026-03-17
|
||||
**Status**: Draft
|
||||
**Input**: Analytics dashboard with time-series graphs, PWA conversion with push notifications, 6 UX fixes, website messaging update, and MCP prompt additions.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **Chart rendering**: Use a lightweight SVG-based chart approach in Svelte to keep the bundle small and avoid heavy external dependencies.
|
||||
2. **Push notifications**: Use the Web Push API with VAPID keys. The server generates VAPID keys on first run and stores them in the data directory. Push subscriptions are stored per-user in SQLite.
|
||||
3. **PWA manifest**: Standard web app manifest with SynapBus branding. Service worker caches static assets and handles offline fallback.
|
||||
4. **Font size range**: 12px to 24px in 2px increments, default 16px. Stored in localStorage per user.
|
||||
5. **Version endpoint**: The Go binary embeds the git tag at build time via `-ldflags`. A `/api/version` endpoint exposes it.
|
||||
6. **Analytics data source**: All analytics are derived from existing message data in SQLite. No new data collection. Aggregation queries run on-demand with reasonable caching (60s TTL).
|
||||
7. **Agent name editability**: Only the `display_name` field is editable (the `name` field is immutable as it's used for routing).
|
||||
8. **Website repo**: Located at `~/repos/synapbus-website/`, Astro + Tailwind stack, deployed to Cloudflare Pages.
|
||||
9. **MCP prompts**: Added as MCP protocol prompt resources, discoverable via `prompts/list` and invocable via `prompts/get`.
|
||||
10. **Mention/channel validation**: Uses existing API data (agent list, channel list) to validate references at render time, not at send time.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Analytics Dashboard (Priority: P1)
|
||||
|
||||
A human owner opens the SynapBus Web UI dashboard and sees an at-a-glance overview of messaging activity: a time-series graph of messages over time, the top 5 most active agents, the top 5 busiest channels, and summary cards showing total channels and agents. They can switch the time span to zoom in on the last hour or zoom out to the last month.
|
||||
|
||||
**Why this priority**: The dashboard is currently minimal (recent messages list). Analytics give the human owner visibility into agent activity patterns, helping them identify bottlenecks, inactive agents, and communication hotspots.
|
||||
|
||||
**Independent Test**: Navigate to dashboard, verify graph renders with data, switch time spans, confirm top-5 lists and counters update.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** messages exist in the system, **When** the user navigates to the dashboard, **Then** a time-series bar/line chart displays message count over time with the default span (24h).
|
||||
2. **Given** the dashboard is displayed, **When** the user clicks a time span button (1h, 4h, 24h, 7d, 1month), **Then** the chart and all statistics update to reflect only that time window.
|
||||
3. **Given** 10 agents have sent messages, **When** viewing the dashboard, **Then** the "Top 5 Agents" section shows the 5 agents with the most messages in the selected time span, with message counts.
|
||||
4. **Given** 20 channels have messages, **When** viewing the dashboard, **Then** the "Top 5 Channels" section shows the 5 channels with the most messages in the selected time span, with message counts.
|
||||
5. **Given** the system has 8 channels and 12 agents, **When** viewing the dashboard, **Then** summary cards show "8 Channels" and "12 Agents".
|
||||
6. **Given** no messages exist in the selected time span, **When** viewing the dashboard, **Then** the chart shows an empty state with "No messages in this period" and counters show zero.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — PWA with Push Notifications (Priority: P1)
|
||||
|
||||
A human owner installs SynapBus as a PWA on their desktop or mobile device. They receive push notifications when high-priority DMs arrive or when agents mention them. The app works offline showing cached data and syncs when connectivity returns.
|
||||
|
||||
**Why this priority**: SynapBus is a messaging hub — users need instant awareness of agent activity without keeping a browser tab open. PWA installation provides native-app-like experience on all platforms.
|
||||
|
||||
**Independent Test**: Install the PWA on desktop Chrome, verify it launches as standalone app. Send a high-priority DM and verify push notification appears. Disconnect network, verify cached pages still load.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user visits SynapBus in Chrome/Edge/Safari, **When** the browser detects the PWA manifest, **Then** an "Install" prompt is available (browser-native or custom banner).
|
||||
2. **Given** the user has installed the PWA, **When** they launch it, **Then** it opens as a standalone window with SynapBus branding (no browser chrome).
|
||||
3. **Given** the user enables push notifications, **When** a DM with priority >= 7 arrives, **Then** a push notification appears with sender name and message preview.
|
||||
4. **Given** the user enables push notifications, **When** they are @mentioned in a channel, **Then** a push notification appears with channel name and mention context.
|
||||
5. **Given** the user is offline, **When** they open the PWA, **Then** previously loaded pages render from cache with a "You are offline" indicator.
|
||||
6. **Given** the PWA is open on a mobile device (< 768px viewport), **When** viewing any page, **Then** the layout adapts responsively (sidebar collapses to hamburger, content fills width).
|
||||
7. **Given** the PWA is open on a tablet (768px-1024px), **When** viewing any page, **Then** the layout adapts with an appropriate intermediate layout.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Auto-Resizing Message Textarea (Priority: P1)
|
||||
|
||||
A user composes a long message in the ComposeForm. The textarea automatically grows to fit the content. When the text exceeds a maximum height, a scrollbar appears instead of the textarea continuing to grow infinitely.
|
||||
|
||||
**Why this priority**: The current fixed-height textarea is too small for multi-line messages, forcing users to scroll within a tiny box. This is the most common UX friction point.
|
||||
|
||||
**Independent Test**: Type progressively longer text in the compose form, verify the textarea grows. Paste a very long message, verify scrollbar appears at max height.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the compose form is empty, **When** the user starts typing, **Then** the textarea has a minimum height of 3 lines.
|
||||
2. **Given** the user types multiple lines, **When** the content exceeds 3 lines, **Then** the textarea height grows to fit the content.
|
||||
3. **Given** the textarea has grown, **When** the content exceeds 12 lines (approximately 240px), **Then** the textarea stops growing and a vertical scrollbar appears.
|
||||
4. **Given** the user sends the message, **When** the textarea clears, **Then** it shrinks back to the minimum 3-line height.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Smart Mention/Channel Highlighting (Priority: P2)
|
||||
|
||||
When a message body contains @agentname or #channelname, the renderer checks if the referenced entity exists. Existing entities are highlighted as clickable links. Deleted entities show an "inactive" badge. Text that coincidentally contains @ or # symbols but doesn't reference any known entity is rendered as plain text.
|
||||
|
||||
**Why this priority**: Current highlighting blindly styles all @/# tokens, which creates confusing UI when entities are deleted or when text naturally contains these symbols (e.g., "issue #42" or "email@example.com").
|
||||
|
||||
**Independent Test**: Create a message with @existing-agent, @deleted-agent, @never-existed, #real-channel, #deleted-channel, and #random-text. Verify correct rendering for each case.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a message contains "@research-agent" and research-agent exists, **When** rendering, **Then** "@research-agent" is highlighted as a clickable link to the agent page.
|
||||
2. **Given** a message contains "#bugs-synapbus" and the channel exists, **When** rendering, **Then** "#bugs-synapbus" is highlighted as a clickable link to the channel.
|
||||
3. **Given** a message contains "@old-agent" and old-agent was deleted, **When** rendering, **Then** "@old-agent" shows with a small "inactive" badge/label.
|
||||
4. **Given** a message contains "#archived-channel" and the channel was deleted, **When** rendering, **Then** "#archived-channel" shows with a small "inactive" badge/label.
|
||||
5. **Given** a message contains "@nonexistent" and no agent by that name ever existed, **When** rendering, **Then** "@nonexistent" is rendered as plain text (no highlighting).
|
||||
6. **Given** a message contains "email@example.com" or "issue #42", **When** rendering, **Then** these are rendered as plain text (not treated as mentions/channels).
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — Editable Agent Display Name (Priority: P2)
|
||||
|
||||
An owner views an agent's detail page and clicks on the agent's display name to edit it inline. The change is saved and reflected immediately across the UI.
|
||||
|
||||
**Why this priority**: Currently agent names can only be set at registration. Owners need to rename agents as their roles evolve.
|
||||
|
||||
**Independent Test**: Navigate to agent detail page, click display name, change it, verify it saves and updates in the sidebar/header.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is on the agent detail page, **When** they click the display name, **Then** it becomes an editable text input pre-filled with the current name.
|
||||
2. **Given** the user has edited the name, **When** they press Enter or click away, **Then** the new name is saved via API and the UI updates.
|
||||
3. **Given** the user edits the name to empty, **When** they try to save, **Then** validation prevents saving and shows an error.
|
||||
4. **Given** the name was changed, **When** viewing the sidebar agent list, **Then** the updated name appears.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 — Editable Human Display Name in Settings (Priority: P2)
|
||||
|
||||
A human user opens Settings and edits their own display name. The change persists and is reflected in the header and anywhere the user's name appears.
|
||||
|
||||
**Why this priority**: Users should be able to personalize their identity in the system.
|
||||
|
||||
**Independent Test**: Go to Settings, change display name, verify it updates in the header.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user navigates to Settings, **When** they see the account section, **Then** their current display name is shown in an editable field.
|
||||
2. **Given** the user changes their display name and clicks Save, **When** the page reloads, **Then** the new name persists.
|
||||
3. **Given** the user changes their name, **When** viewing the header, **Then** the updated name is shown.
|
||||
|
||||
---
|
||||
|
||||
### User Story 7 — Font Size Preference (Priority: P3)
|
||||
|
||||
A user opens Settings and adjusts the font size using -/+ controls. The change applies immediately across the entire UI and persists between sessions.
|
||||
|
||||
**Why this priority**: Accessibility feature — users with different vision needs or screen sizes benefit from adjustable text.
|
||||
|
||||
**Independent Test**: Go to Settings, click + to increase font size, verify all text across the app grows. Reload the page, verify the setting persists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user is on the Settings page, **When** they see the font size section, **Then** a -/+ control shows the current size (default: 16px).
|
||||
2. **Given** the user clicks +, **When** the font size is less than 24px, **Then** the font size increases by 2px and the entire UI updates immediately.
|
||||
3. **Given** the user clicks -, **When** the font size is greater than 12px, **Then** the font size decreases by 2px.
|
||||
4. **Given** the user has set font size to 20px, **When** they close and reopen the app, **Then** the font size is still 20px.
|
||||
5. **Given** the font size is at the minimum (12px), **When** the user clicks -, **Then** nothing happens (button appears disabled).
|
||||
|
||||
---
|
||||
|
||||
### User Story 8 — Version Display (Priority: P3)
|
||||
|
||||
A user sees the current SynapBus version (git tag) in the Web UI footer, along with a link to the GitHub repository. This helps with troubleshooting and identifying which version is deployed.
|
||||
|
||||
**Why this priority**: Essential for debugging but low user-facing value. Simple to implement.
|
||||
|
||||
**Independent Test**: Check the footer of any page, verify version string matches the deployed git tag and GitHub link is clickable.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** SynapBus is built from git tag v0.7.0, **When** viewing any page, **Then** the footer shows "v0.7.0" with a link to the GitHub repository.
|
||||
2. **Given** SynapBus is built from a commit without a tag, **When** viewing any page, **Then** the footer shows the short commit hash (e.g., "dev-abc1234").
|
||||
3. **Given** the footer shows a version, **When** the user clicks the version text, **Then** a new tab opens to the GitHub repo releases page.
|
||||
|
||||
---
|
||||
|
||||
### User Story 9 — MCP Prompts (Priority: P2)
|
||||
|
||||
A developer using Claude Code or Gemini CLI with SynapBus MCP discovers pre-built prompts that help them accomplish common tasks: checking agent health, reviewing recent activity, summarizing channel conversations, and getting a daily digest.
|
||||
|
||||
**Why this priority**: MCP prompts improve developer UX by providing ready-made workflows for common agent management tasks.
|
||||
|
||||
**Independent Test**: Connect an MCP client, call `prompts/list`, verify prompts appear. Invoke a prompt, verify it returns a useful formatted response.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an MCP client connects, **When** it calls `prompts/list`, **Then** 3-5 prompts are listed with names and descriptions.
|
||||
2. **Given** a user invokes the "daily-digest" prompt, **When** it executes, **Then** it returns a summary of recent activity including message counts, active agents, and notable events.
|
||||
3. **Given** a user invokes the "agent-health-check" prompt, **When** it executes, **Then** it returns the status of all agents including last-seen times and pending message counts.
|
||||
4. **Given** a user invokes the "channel-overview" prompt, **When** it executes, **Then** it returns a formatted list of channels with member counts and recent activity.
|
||||
|
||||
---
|
||||
|
||||
### User Story 10 — Website Messaging Update (Priority: P3)
|
||||
|
||||
The synapbus.dev website is updated to clearly communicate that SynapBus is a practical solution for individuals and small teams to build local agent networks. The site emphasizes human-agent collaboration through the Web UI on desktop and mobile.
|
||||
|
||||
**Why this priority**: Marketing/positioning update. Important for adoption but not a functional feature.
|
||||
|
||||
**Independent Test**: Visit synapbus.dev, verify messaging reflects individual/small team use case, agent collaboration narrative, and Web UI screenshots.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a visitor lands on synapbus.dev, **When** they read the hero section, **Then** the messaging emphasizes "practical local agent network for individuals and small teams".
|
||||
2. **Given** the visitor scrolls, **When** they see feature sections, **Then** content highlights: agent collaboration, human-agent interaction via Web UI, desktop/mobile support.
|
||||
3. **Given** the visitor views screenshots, **When** they see the Web UI images, **Then** screenshots show the analytics dashboard and mobile responsive views.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the analytics time span has zero messages? → Empty state with informative message.
|
||||
- What happens when push notification permission is denied? → Graceful degradation, no error. A small indicator in Settings shows notifications are disabled.
|
||||
- What happens when an agent is deleted while viewing its detail page? → Redirect to agents list with a toast notification.
|
||||
- What happens when the service worker cache is corrupted? → Force re-cache on next online visit.
|
||||
- What happens when the user sets font size and clears browser storage? → Reverts to default 16px.
|
||||
- What happens when @mention text contains special regex characters? → Escaping prevents rendering errors (e.g., "@agent++" renders as plain text).
|
||||
- What happens when version info is not embedded at build time? → Footer shows "dev" as fallback.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
**Analytics Dashboard**
|
||||
- **FR-001**: System MUST provide a REST endpoint returning message count aggregated by time bucket for a given time span.
|
||||
- **FR-002**: System MUST provide a REST endpoint returning top N agents by message count for a given time span.
|
||||
- **FR-003**: System MUST provide a REST endpoint returning top N channels by message count for a given time span.
|
||||
- **FR-004**: System MUST provide a REST endpoint returning total agent count and total channel count.
|
||||
- **FR-005**: Dashboard MUST display a time-series chart of messages with selectable spans: 1h, 4h, 24h, 7d, 1month.
|
||||
- **FR-006**: Dashboard MUST display top 5 agents and top 5 channels for the selected time span.
|
||||
- **FR-007**: Dashboard MUST display summary cards for total channels and total agents.
|
||||
|
||||
**PWA**
|
||||
- **FR-008**: System MUST serve a valid web app manifest (manifest.json) with app name, icons, theme color, and display mode "standalone".
|
||||
- **FR-009**: System MUST register a service worker that caches static assets for offline access.
|
||||
- **FR-010**: System MUST support Web Push API for sending notifications to subscribed users.
|
||||
- **FR-011**: System MUST provide endpoints for push subscription management (subscribe/unsubscribe).
|
||||
- **FR-012**: System MUST send push notifications for DMs with priority >= 7 and @mentions.
|
||||
- **FR-013**: PWA MUST be responsive across mobile (< 768px), tablet (768-1024px), and desktop (> 1024px) viewports.
|
||||
|
||||
**UX Fixes**
|
||||
- **FR-014**: Compose textarea MUST auto-resize from a minimum of 3 lines to a maximum of 12 lines, then show scrollbar.
|
||||
- **FR-015**: Agent display_name MUST be editable inline on the agent detail page.
|
||||
- **FR-016**: Human user display_name MUST be editable on the Settings page.
|
||||
- **FR-017**: Message renderer MUST validate @mentions and #channels against known entities: highlight existing, badge "inactive" for deleted, plain text for unknown.
|
||||
- **FR-018**: Settings page MUST provide font size -/+ controls (12px to 24px, 2px steps) that apply globally and persist in localStorage.
|
||||
|
||||
**Version & Metadata**
|
||||
- **FR-019**: System MUST expose a `/api/version` endpoint returning the build version (git tag or commit hash).
|
||||
- **FR-020**: Web UI footer MUST display the version with a link to the GitHub repository.
|
||||
|
||||
**MCP Prompts**
|
||||
- **FR-021**: MCP server MUST register 3-5 prompt resources discoverable via `prompts/list`.
|
||||
- **FR-022**: Each prompt MUST return actionable, formatted text when invoked via `prompts/get`.
|
||||
|
||||
**Website**
|
||||
- **FR-023**: Website hero and feature sections MUST communicate individual/small-team positioning and agent collaboration narrative.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **PushSubscription**: User ID, endpoint URL, auth key, p256dh key, created timestamp. One user can have multiple subscriptions (multi-device).
|
||||
- **AnalyticsTimespan**: Enum of supported time windows (1h, 4h, 24h, 7d, 30d) used to parameterize analytics queries.
|
||||
- **MCPPrompt**: Name, description, argument schema. Registered at server startup.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Dashboard loads and renders analytics within 2 seconds for time spans up to 1 month of data.
|
||||
- **SC-002**: Users can install the PWA and receive push notifications on desktop Chrome, Edge, and Safari.
|
||||
- **SC-003**: Compose textarea accommodates messages up to 500 lines without UX degradation.
|
||||
- **SC-004**: Mention/channel highlighting correctly identifies 100% of existing, deleted, and non-existent entities in test scenarios.
|
||||
- **SC-005**: Font size preference persists across browser sessions and applies to all text elements.
|
||||
- **SC-006**: Version information is accurately displayed matching the deployed build.
|
||||
- **SC-007**: MCP prompts are discoverable and return useful formatted responses for all registered prompts.
|
||||
- **SC-008**: Website clearly communicates individual/small-team use case to new visitors.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Tasks: SynapBus v0.7.0 — WebUI Analytics, PWA, UX Fixes, Website, MCP Prompts
|
||||
|
||||
**Branch**: `008-webui-pwa-analytics`
|
||||
**Plan**: [plan.md](plan.md)
|
||||
**Generated**: 2026-03-17
|
||||
|
||||
## Phase 1: Backend Analytics + Version API
|
||||
|
||||
- [x] **T-001**: Create `internal/api/analytics_handler.go` with analytics endpoints (timeline, top-agents, top-channels, summary)
|
||||
- [x] **T-002**: Create `internal/api/version_handler.go` with GET /api/version endpoint
|
||||
- [x] **T-003**: Register analytics and version routes in `internal/api/router.go`
|
||||
- [x] **T-004**: Wire version string from `cmd/synapbus/main.go` to version handler
|
||||
- [x] **T-005**: Write Go tests for analytics handler (table-driven, various spans, empty data)
|
||||
- [x] **T-006**: Write Go tests for version handler
|
||||
|
||||
## Phase 2: Frontend Analytics Dashboard
|
||||
|
||||
- [x] **T-007**: Add analytics and version API client methods to `web/src/lib/api/client.ts`
|
||||
- [x] **T-008**: Create `web/src/lib/components/AnalyticsChart.svelte` — SVG bar chart with time spans
|
||||
- [x] **T-009**: Create `web/src/lib/components/TopList.svelte` — ranked list component
|
||||
- [x] **T-010**: Redesign `web/src/routes/+page.svelte` dashboard with analytics, stat cards, span selector
|
||||
- [x] **T-011**: Add version footer to `web/src/routes/+layout.svelte` with GitHub link
|
||||
|
||||
## Phase 3: UX Fixes — Textarea, Names, Font Size
|
||||
|
||||
- [x] **T-012**: Modify `web/src/lib/components/ComposeForm.svelte` — auto-resize textarea (min 3 lines, max 12 lines)
|
||||
- [x] **T-013**: Add inline display_name editing to `web/src/routes/agents/[name]/+page.svelte`
|
||||
- [x] **T-014**: Create `web/src/lib/stores/fontSize.ts` — font size store synced with localStorage
|
||||
- [x] **T-015**: Add display name edit + font size -/+ controls to `web/src/routes/settings/+page.svelte`
|
||||
- [x] **T-016**: Create `PUT /api/auth/profile` endpoint in Go for human display name editing
|
||||
- [x] **T-017**: Apply font size CSS custom property in `web/src/routes/+layout.svelte`
|
||||
|
||||
## Phase 4: Smart Mention/Channel Highlighting
|
||||
|
||||
- [x] **T-018**: Create `web/src/lib/stores/entities.ts` — cached agent/channel lists for mention validation
|
||||
- [x] **T-019**: Modify `web/src/lib/components/MessageBody.svelte` — smart mention/channel highlighting with inactive badges
|
||||
|
||||
## Phase 5: PWA — Manifest, Service Worker, Push Notifications
|
||||
|
||||
- [x] **T-020**: Create `schema/012_push_subscriptions.sql` migration
|
||||
- [x] **T-021**: Create `internal/push/service.go` — VAPID key management, Web Push sending
|
||||
- [x] **T-022**: Create `internal/push/store.go` — SQLite push subscription CRUD
|
||||
- [x] **T-023**: Create `internal/api/push_handler.go` — subscribe, unsubscribe, VAPID key endpoints
|
||||
- [x] **T-024**: Register push routes in `internal/api/router.go` and wire in `cmd/synapbus/main.go`
|
||||
- [x] **T-025**: Create `web/static/manifest.json` PWA manifest with icons
|
||||
- [x] **T-026**: Create `web/static/sw.js` service worker (cache-first static, network-only API)
|
||||
- [x] **T-027**: Register service worker in layout + add push notification toggle to Settings
|
||||
- [x] **T-028**: Integrate push sending into message delivery for priority >= 7 DMs and @mentions
|
||||
- [x] **T-029**: Write Go tests for push service and store
|
||||
- [x] **T-030**: Generate PWA icons (192x192, 512x512)
|
||||
|
||||
## Phase 6: MCP Prompts
|
||||
|
||||
- [x] **T-031**: Create `internal/mcp/prompts.go` with 4 prompts (daily-digest, agent-health-check, channel-overview, debug-agent)
|
||||
- [x] **T-032**: Register prompts in `internal/mcp/server.go`
|
||||
- [x] **T-033**: Write Go tests for MCP prompts
|
||||
|
||||
## Phase 7: Website Update
|
||||
|
||||
- [ ] **T-034**: Update `~/repos/synapbus-website/` hero and feature messaging for individual/small-team positioning
|
||||
- [ ] **T-035**: Add/update screenshots showing analytics dashboard and mobile views
|
||||
|
||||
## Phase 8: Integration Testing & Polish
|
||||
|
||||
- [x] **T-036**: Run full test suite (`make test`), fix any failures
|
||||
- [x] **T-037**: Build and verify end-to-end (`make build && ./bin/synapbus serve`)
|
||||
- [ ] **T-038**: Manual verification of all features via browser and curl
|
||||
- [ ] **T-039**: Create git tag v0.7.0
|
||||
@@ -480,17 +480,17 @@ func TestE2E_ChannelMessaging(t *testing.T) {
|
||||
t.Errorf("send status = %v, want sent", sendResult["status"])
|
||||
}
|
||||
|
||||
// Bob reads his inbox and should see the channel message.
|
||||
inbox := bobClient.CallTool("execute", map[string]any{
|
||||
"code": `call("read_inbox", { include_read: true })`,
|
||||
// Bob reads channel messages (DMs only sent for @mentions).
|
||||
chMsgs := bobClient.CallTool("execute", map[string]any{
|
||||
"code": `call("get_channel_messages", { channel_name: "project-x", limit: 10 })`,
|
||||
})
|
||||
inboxData := unwrapCallResult(t, inbox)
|
||||
count := inboxData["count"].(float64)
|
||||
chData := unwrapCallResult(t, chMsgs)
|
||||
count := chData["count"].(float64)
|
||||
if count < 1 {
|
||||
t.Fatalf("Bob's inbox count = %v, want >= 1", count)
|
||||
t.Fatalf("channel message count = %v, want >= 1", count)
|
||||
}
|
||||
|
||||
messages := inboxData["messages"].([]any)
|
||||
messages := chData["messages"].([]any)
|
||||
found := false
|
||||
for _, m := range messages {
|
||||
msg := m.(map[string]any)
|
||||
@@ -500,7 +500,7 @@ func TestE2E_ChannelMessaging(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Bob did not receive the channel message")
|
||||
t.Error("channel message not found via get_channel_messages")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -20,6 +20,10 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
@@ -128,7 +132,7 @@
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
.sidebar-item {
|
||||
@apply flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors cursor-pointer;
|
||||
@apply flex items-center gap-2 px-3 py-2 md:py-1.5 min-h-[44px] md:min-h-0 rounded text-sm transition-colors cursor-pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>SynapBus</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
@@ -65,9 +65,13 @@ export const messages = {
|
||||
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number }) =>
|
||||
request<any>('POST', '/api/messages', body),
|
||||
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
|
||||
search: (q: string, limit?: number) => {
|
||||
search: (q: string, opts?: { limit?: number; channel?: string; agent?: string; after?: string; before?: string }) => {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (limit) qs.set('limit', String(limit));
|
||||
if (opts?.limit) qs.set('limit', String(opts.limit));
|
||||
if (opts?.channel) qs.set('channel', opts.channel);
|
||||
if (opts?.agent) qs.set('agent', opts.agent);
|
||||
if (opts?.after) qs.set('after', opts.after);
|
||||
if (opts?.before) qs.set('before', opts.before);
|
||||
return request<{ messages: any[]; query: string; total: number }>('GET', `/api/messages/search?${qs}`);
|
||||
}
|
||||
};
|
||||
@@ -196,4 +200,36 @@ export const notificationsApi = {
|
||||
request<{ status: string }>('POST', '/api/notifications/mark-read', { type, target, last_message_id: lastMessageId })
|
||||
};
|
||||
|
||||
// Analytics
|
||||
export const analytics = {
|
||||
timeline: (span = '24h') =>
|
||||
request<{ span: string; buckets: { time: string; count: number }[]; total: number }>('GET', `/api/analytics/timeline?span=${span}`),
|
||||
topAgents: (span = '24h', limit = 5) =>
|
||||
request<{ span: string; agents: { name: string; display_name: string; count: number }[] }>('GET', `/api/analytics/top-agents?span=${span}&limit=${limit}`),
|
||||
topChannels: (span = '24h', limit = 5) =>
|
||||
request<{ span: string; channels: { name: string; count: number }[] }>('GET', `/api/analytics/top-channels?span=${span}&limit=${limit}`),
|
||||
summary: () =>
|
||||
request<{ total_agents: number; total_channels: number; total_messages: number }>('GET', '/api/analytics/summary')
|
||||
};
|
||||
|
||||
// Version
|
||||
export const version = {
|
||||
get: () => request<{ version: string; repo: string }>('GET', '/api/version')
|
||||
};
|
||||
|
||||
// Push notifications
|
||||
export const push = {
|
||||
subscribe: (subscription: { endpoint: string; keys: { p256dh: string; auth: string } }) =>
|
||||
request<{ id: number; message: string }>('POST', '/api/push/subscribe', subscription),
|
||||
unsubscribe: (endpoint: string) =>
|
||||
request<{ message: string }>('DELETE', '/api/push/subscribe', { endpoint }),
|
||||
vapidKey: () => request<{ vapid_public_key: string }>('GET', '/api/push/vapid-key')
|
||||
};
|
||||
|
||||
// User profile
|
||||
export const profile = {
|
||||
update: (body: { display_name: string }) =>
|
||||
request<{ message: string; user: { id: number; username: string; display_name: string; role: string } }>('PUT', '/api/auth/profile', body)
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
let { buckets = [], span = '24h' }: { buckets: { time: string; count: number }[]; span?: string } = $props();
|
||||
|
||||
const chartHeight = 160;
|
||||
const chartPadding = { top: 10, right: 10, bottom: 24, left: 40 };
|
||||
|
||||
let containerWidth = $state(600);
|
||||
let containerEl: HTMLDivElement | undefined = $state(undefined);
|
||||
let hoveredIndex = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (containerEl) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
containerWidth = entries[0]?.contentRect.width ?? 600;
|
||||
});
|
||||
observer.observe(containerEl);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
let maxCount = $derived(Math.max(...buckets.map(b => b.count), 1));
|
||||
let innerWidth = $derived(containerWidth - chartPadding.left - chartPadding.right);
|
||||
let innerHeight = $derived(chartHeight - chartPadding.top - chartPadding.bottom);
|
||||
let barWidth = $derived(buckets.length > 0 ? Math.max(2, (innerWidth / buckets.length) - 2) : 0);
|
||||
|
||||
function formatLabel(time: string): string {
|
||||
if (span === '1h' || span === '4h') {
|
||||
// Show HH:MM
|
||||
const parts = time.split(' ');
|
||||
return parts[parts.length - 1] || time;
|
||||
}
|
||||
if (span === '24h') {
|
||||
// Show hour
|
||||
const match = time.match(/(\d{2}):00/);
|
||||
return match ? match[1] + 'h' : time;
|
||||
}
|
||||
// 7d, 30d — show date
|
||||
const match = time.match(/(\d{2})-(\d{2})$/);
|
||||
return match ? `${match[1]}/${match[2]}` : time;
|
||||
}
|
||||
|
||||
// Show about 6-8 labels max
|
||||
let labelInterval = $derived(Math.max(1, Math.floor(buckets.length / 7)));
|
||||
</script>
|
||||
|
||||
<div bind:this={containerEl} class="w-full relative">
|
||||
{#if buckets.length === 0}
|
||||
<div class="flex items-center justify-center h-[160px] text-sm text-text-secondary">
|
||||
No messages in this period
|
||||
</div>
|
||||
{:else}
|
||||
<svg width={containerWidth} height={chartHeight} class="overflow-visible">
|
||||
<!-- Y-axis gridlines -->
|
||||
{#each [0, 0.25, 0.5, 0.75, 1] as tick}
|
||||
<line
|
||||
x1={chartPadding.left}
|
||||
y1={chartPadding.top + innerHeight * (1 - tick)}
|
||||
x2={chartPadding.left + innerWidth}
|
||||
y2={chartPadding.top + innerHeight * (1 - tick)}
|
||||
stroke="var(--border)"
|
||||
stroke-width="0.5"
|
||||
stroke-dasharray={tick === 0 ? 'none' : '3,3'}
|
||||
/>
|
||||
<text
|
||||
x={chartPadding.left - 6}
|
||||
y={chartPadding.top + innerHeight * (1 - tick) + 4}
|
||||
text-anchor="end"
|
||||
fill="var(--text-secondary)"
|
||||
font-size="10"
|
||||
>
|
||||
{Math.round(maxCount * tick)}
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
<!-- Bars -->
|
||||
{#each buckets as bucket, i}
|
||||
{@const barHeight = (bucket.count / maxCount) * innerHeight}
|
||||
{@const x = chartPadding.left + (i * (innerWidth / buckets.length)) + 1}
|
||||
{@const y = chartPadding.top + innerHeight - barHeight}
|
||||
<rect
|
||||
{x}
|
||||
{y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
rx="2"
|
||||
fill={hoveredIndex === i ? 'var(--accent-purple)' : 'var(--accent-blue)'}
|
||||
opacity={hoveredIndex === i ? 1 : 0.8}
|
||||
class="transition-all duration-100"
|
||||
onmouseenter={() => hoveredIndex = i}
|
||||
onmouseleave={() => hoveredIndex = null}
|
||||
/>
|
||||
<!-- X-axis labels -->
|
||||
{#if i % labelInterval === 0}
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={chartHeight - 4}
|
||||
text-anchor="middle"
|
||||
fill="var(--text-secondary)"
|
||||
font-size="9"
|
||||
>
|
||||
{formatLabel(bucket.time)}
|
||||
</text>
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<!-- Tooltip -->
|
||||
{#if hoveredIndex !== null && buckets[hoveredIndex]}
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 bg-bg-secondary border border-border rounded px-2 py-1 text-xs shadow-lg pointer-events-none z-10">
|
||||
<span class="text-text-secondary">{buckets[hoveredIndex].time}</span>
|
||||
<span class="font-bold text-text-primary ml-1">{buckets[hoveredIndex].count} msgs</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -51,8 +51,33 @@
|
||||
showSuggestions = false;
|
||||
}
|
||||
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state(undefined);
|
||||
|
||||
function autoResize() {
|
||||
if (!textareaEl) return;
|
||||
// Reset to measure true scrollHeight
|
||||
textareaEl.style.height = '80px';
|
||||
const maxHeight = 240; // ~12 lines
|
||||
const scrollHeight = textareaEl.scrollHeight;
|
||||
if (scrollHeight > maxHeight) {
|
||||
textareaEl.style.height = maxHeight + 'px';
|
||||
textareaEl.style.overflowY = 'auto';
|
||||
textareaEl.classList.remove('overflow-hidden');
|
||||
} else {
|
||||
textareaEl.style.height = Math.max(80, scrollHeight) + 'px';
|
||||
textareaEl.style.overflowY = 'hidden';
|
||||
textareaEl.classList.add('overflow-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function isMobile(): boolean {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// On mobile, Enter inserts newline (send via button only)
|
||||
// On desktop, Enter sends, Shift+Enter inserts newline
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile()) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
@@ -84,6 +109,10 @@
|
||||
priority = 5;
|
||||
subject = '';
|
||||
channelId = undefined;
|
||||
if (textareaEl) {
|
||||
textareaEl.style.height = '72px';
|
||||
textareaEl.style.overflowY = 'hidden';
|
||||
}
|
||||
onSent();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to send message';
|
||||
@@ -140,11 +169,14 @@
|
||||
|
||||
<!-- Message body -->
|
||||
<textarea
|
||||
placeholder="Write a message..."
|
||||
class="w-full px-4 py-3 bg-transparent text-sm text-text-primary placeholder-text-secondary resize-none outline-none min-h-[80px]"
|
||||
bind:this={textareaEl}
|
||||
placeholder="Write a message... {isMobile() ? '' : '(Shift+Enter for new line)'}"
|
||||
class="w-full px-4 py-3 bg-transparent text-sm text-text-primary placeholder-text-secondary resize-none outline-none overflow-hidden"
|
||||
style="min-height: 80px; max-height: 240px;"
|
||||
bind:value={body}
|
||||
rows="3"
|
||||
onkeydown={handleKeydown}
|
||||
oninput={autoResize}
|
||||
></textarea>
|
||||
|
||||
<!-- Options row -->
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { onMenuToggle = () => {} }: { onMenuToggle?: () => void } = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
function handleSearch(e: SubmitEvent) {
|
||||
@@ -16,33 +18,43 @@
|
||||
const path = $page.url.pathname;
|
||||
if (path === '/') return 'Dashboard';
|
||||
if (path.startsWith('/conversations/')) return 'Thread';
|
||||
if (path === '/conversations') return 'Conversations';
|
||||
if (path === '/conversations') return 'Search';
|
||||
if (path.startsWith('/channels/')) return '#' + ($page.params.name ?? '');
|
||||
if (path === '/channels') return 'Channels';
|
||||
if (path.startsWith('/dm/')) return ($page.params.name ?? 'DM');
|
||||
if (path.startsWith('/agents/')) return ($page.params.name ?? 'Agent');
|
||||
if (path === '/agents') return 'Agents';
|
||||
if (path === '/settings/api-keys') return 'API Keys';
|
||||
if (path === '/settings') return 'Settings';
|
||||
return 'SynapBus';
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="flex items-center gap-4 h-12 px-5 border-b border-border bg-bg-primary flex-shrink-0">
|
||||
<h2 class="font-display font-bold text-text-primary text-base">{pageTitle()}</h2>
|
||||
<header class="flex items-center gap-3 md:gap-4 h-12 px-3 md:px-5 border-b border-border bg-bg-primary flex-shrink-0">
|
||||
<!-- Hamburger menu button (mobile only) -->
|
||||
<button
|
||||
class="md:hidden flex items-center justify-center w-8 h-8 rounded text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors flex-shrink-0"
|
||||
onclick={onMenuToggle}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 class="font-display font-bold text-text-primary text-base truncate">{pageTitle()}</h2>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Search -->
|
||||
<form class="max-w-xs w-full" onsubmit={handleSearch}>
|
||||
<form class="w-full max-w-xs md:max-w-none md:w-80" onsubmit={handleSearch}>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
class="w-full pl-8 pr-3 py-1.5 text-xs bg-bg-tertiary border border-border rounded text-text-primary placeholder-text-secondary focus:border-border-active focus:ring-0 outline-none"
|
||||
placeholder="Search messages..."
|
||||
class="w-full pl-9 pr-3 py-2 text-sm bg-bg-tertiary border border-border rounded-md text-text-primary placeholder-text-secondary focus:border-border-active focus:ring-0 outline-none"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<script lang="ts">
|
||||
import { lookupAgent, lookupChannel, loadEntities, entitiesLoaded } from '$lib/stores/entities';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
let { body, truncate }: { body: string; truncate?: number } = $props();
|
||||
|
||||
// Ensure entities are loaded for smart highlighting
|
||||
let _entitiesInit = $state(false);
|
||||
$effect(() => {
|
||||
if (!_entitiesInit) {
|
||||
_entitiesInit = true;
|
||||
if (!get(entitiesLoaded)) loadEntities();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer with @mentions, #channels, and URL auto-linking.
|
||||
* No external dependencies. Input is sanitized (HTML stripped) before processing.
|
||||
* Smart highlighting: validates @mentions and #channels against known entities.
|
||||
*/
|
||||
|
||||
// Strip all HTML tags from raw input to prevent XSS
|
||||
function stripHtml(text: string): string {
|
||||
return text.replace(/<[^>]*>?/g, '');
|
||||
}
|
||||
|
||||
// Escape HTML special characters in text content
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Process inline markdown: bold, italic, code, URLs, @mentions, #channels
|
||||
function processInline(text: string): string {
|
||||
// Inline code first (so content inside is not further processed)
|
||||
let result = '';
|
||||
const codeRegex = /`([^`]+)`/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = codeRegex.exec(text)) !== null) {
|
||||
result += processInlineNonCode(text.slice(lastIndex, match.index));
|
||||
result += `<code class="msg-inline-code">${escapeHtml(match[1])}</code>`;
|
||||
lastIndex = codeRegex.lastIndex;
|
||||
}
|
||||
result += processInlineNonCode(text.slice(lastIndex));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Process inline elements that are NOT inside code spans
|
||||
function processInlineNonCode(text: string): string {
|
||||
// Extract URLs BEFORE HTML escaping to avoid breaking them
|
||||
const urlPlaceholders: string[] = [];
|
||||
let s = text.replace(
|
||||
/https?:\/\/[^\s<>()[\]"'`]+/g,
|
||||
(url) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const trailing = url.slice(cleaned.length);
|
||||
const idx = urlPlaceholders.length;
|
||||
urlPlaceholders.push(
|
||||
`<a href="${escapeHtml(cleaned)}" target="_blank" rel="noopener" class="msg-link">${escapeHtml(cleaned)}</a>${escapeHtml(trailing)}`
|
||||
);
|
||||
return `\x01URL${idx}\x01`;
|
||||
}
|
||||
);
|
||||
|
||||
// Now HTML-escape the rest
|
||||
s = escapeHtml(s);
|
||||
|
||||
// Bold: **text**
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic: *text* (but not inside **)
|
||||
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
||||
|
||||
// @mentions — smart validation
|
||||
s = s.replace(
|
||||
/(?<![.\w@])@([\w][\w.-]*)/g,
|
||||
(_match, name) => {
|
||||
const entity = lookupAgent(name);
|
||||
if (entity && entity.exists && !entity.deleted) {
|
||||
return `<a href="/dm/${name}" class="msg-mention">@${name}</a>`;
|
||||
} else if (entity && entity.deleted) {
|
||||
return `<span class="msg-mention msg-mention-inactive">@${name}<span class="msg-badge-inactive">inactive</span></span>`;
|
||||
}
|
||||
// Unknown — render as plain text
|
||||
return `@${name}`;
|
||||
}
|
||||
);
|
||||
|
||||
// #channels — smart validation
|
||||
s = s.replace(
|
||||
/(?<![.\w#])#([\w][\w.-]*)/g,
|
||||
(_match, name) => {
|
||||
// Skip pure numbers (e.g., "issue #42")
|
||||
if (/^\d+$/.test(name)) return `#${name}`;
|
||||
const entity = lookupChannel(name);
|
||||
if (entity && entity.exists && !entity.deleted) {
|
||||
return `<a href="/channels/${name}" class="msg-channel">#${name}</a>`;
|
||||
} else if (entity && entity.deleted) {
|
||||
return `<span class="msg-channel msg-channel-inactive">#${name}<span class="msg-badge-inactive">inactive</span></span>`;
|
||||
}
|
||||
// Unknown — render as plain text
|
||||
return `#${name}`;
|
||||
}
|
||||
);
|
||||
|
||||
// Restore URL placeholders
|
||||
s = s.replace(/\x01URL(\d+)\x01/g, (_, idx) => urlPlaceholders[parseInt(idx)]);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// Parse a full message body into HTML
|
||||
function renderMarkdown(raw: string): string {
|
||||
const sanitized = stripHtml(raw);
|
||||
|
||||
// Handle truncation on the sanitized text
|
||||
let text = sanitized;
|
||||
if (truncate && text.length > truncate) {
|
||||
text = text.slice(0, truncate) + '...';
|
||||
}
|
||||
|
||||
// Fenced code blocks: ```...```
|
||||
const blocks: string[] = [];
|
||||
const codeBlockRegex = /```(?:\w*)\n?([\s\S]*?)```/g;
|
||||
let processed = text.replace(codeBlockRegex, (_match, code) => {
|
||||
const idx = blocks.length;
|
||||
blocks.push(`<pre class="msg-code-block"><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
|
||||
return `\x00BLOCK${idx}\x00`;
|
||||
});
|
||||
|
||||
// Split into paragraphs by blank lines
|
||||
const paragraphs = processed.split(/\n{2,}/);
|
||||
const htmlParts: string[] = [];
|
||||
|
||||
for (const para of paragraphs) {
|
||||
// Check for block placeholder
|
||||
if (/^\x00BLOCK\d+\x00$/.test(para.trim())) {
|
||||
const idx = parseInt(para.trim().replace(/\x00BLOCK(\d+)\x00/, '$1'));
|
||||
htmlParts.push(blocks[idx]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = para.split('\n');
|
||||
|
||||
// Check if this paragraph is a list
|
||||
const isUnordered = lines.every(l => /^\s*[-*]\s/.test(l) || l.trim() === '');
|
||||
const isOrdered = lines.every(l => /^\s*\d+\.\s/.test(l) || l.trim() === '');
|
||||
|
||||
if (isUnordered && lines.some(l => l.trim() !== '')) {
|
||||
const items = lines
|
||||
.filter(l => /^\s*[-*]\s/.test(l))
|
||||
.map(l => `<li>${processInline(l.replace(/^\s*[-*]\s+/, ''))}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ul class="msg-list">${items}</ul>`);
|
||||
} else if (isOrdered && lines.some(l => l.trim() !== '')) {
|
||||
const items = lines
|
||||
.filter(l => /^\s*\d+\.\s/.test(l))
|
||||
.map(l => `<li>${processInline(l.replace(/^\s*\d+\.\s+/, ''))}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ol class="msg-list msg-list-ordered">${items}</ol>`);
|
||||
} else {
|
||||
// Check for headers
|
||||
const headerMatch = para.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
htmlParts.push(`<h${level} class="msg-heading msg-h${level}">${processInline(headerMatch[2])}</h${level}>`);
|
||||
} else {
|
||||
// Regular paragraph - preserve line breaks within
|
||||
const inlineHtml = lines
|
||||
.map(l => {
|
||||
// Replace block placeholders inline
|
||||
if (/\x00BLOCK\d+\x00/.test(l)) {
|
||||
return l.replace(/\x00BLOCK(\d+)\x00/g, (_, idx) => blocks[parseInt(idx)]);
|
||||
}
|
||||
return processInline(l);
|
||||
})
|
||||
.join('<br>');
|
||||
htmlParts.push(`<p>${inlineHtml}</p>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return htmlParts.join('');
|
||||
}
|
||||
|
||||
let rendered = $derived(renderMarkdown(body));
|
||||
</script>
|
||||
|
||||
<div class="msg-body">
|
||||
{@html rendered}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.msg-body {
|
||||
/* Inherit parent text styling */
|
||||
}
|
||||
.msg-body :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
.msg-body :global(p + p) {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.msg-body :global(strong) {
|
||||
font-weight: 700;
|
||||
}
|
||||
.msg-body :global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
.msg-body :global(.msg-inline-code) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.msg-body :global(.msg-code-block) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8em;
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
white-space: pre;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.msg-body :global(.msg-code-block code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg-body :global(.msg-link) {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-link:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.msg-body :global(.msg-mention) {
|
||||
display: inline;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(124, 58, 237, 0.15);
|
||||
color: var(--accent-purple);
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-mention:hover) {
|
||||
background-color: rgba(124, 58, 237, 0.25);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-channel) {
|
||||
display: inline;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(54, 197, 240, 0.15);
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-channel:hover) {
|
||||
background-color: rgba(54, 197, 240, 0.25);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-list) {
|
||||
margin: 0.25em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
.msg-body :global(.msg-list-ordered) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.msg-body :global(.msg-list li) {
|
||||
margin: 0.1em 0;
|
||||
}
|
||||
.msg-body :global(.msg-heading) {
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
margin: 0.4em 0 0.2em;
|
||||
}
|
||||
.msg-body :global(.msg-h1) {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.msg-body :global(.msg-h2) {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.msg-body :global(.msg-h3) {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.msg-body :global(.msg-h4),
|
||||
.msg-body :global(.msg-h5),
|
||||
.msg-body :global(.msg-h6) {
|
||||
font-size: 1em;
|
||||
}
|
||||
.msg-body :global(.msg-mention-inactive) {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.msg-body :global(.msg-channel-inactive) {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.msg-body :global(.msg-badge-inactive) {
|
||||
display: inline-block;
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
padding: 0 0.3em;
|
||||
margin-left: 0.3em;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: var(--accent-red);
|
||||
vertical-align: middle;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { openThread } from '$lib/stores/thread';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
type Message = {
|
||||
id: number;
|
||||
@@ -105,7 +106,7 @@
|
||||
<span class="badge bg-accent-yellow/20 text-accent-yellow">P{msg.priority}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body.length > 300 ? msg.body.slice(0, 300) + '...' : msg.body}</p>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={300} /></div>
|
||||
|
||||
<!-- Thread link -->
|
||||
{#if showConversationLink && msg.conversation_id}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi } from '$lib/api/client';
|
||||
|
||||
let { open = false, onclose = () => {} }: { open?: boolean; onclose?: () => void } = $props();
|
||||
|
||||
let channelList = $state<any[]>([]);
|
||||
let agentList = $state<any[]>([]);
|
||||
let deadLetterCount = $state(0);
|
||||
@@ -36,6 +38,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavClick() {
|
||||
onclose();
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
|
||||
}
|
||||
@@ -55,7 +61,17 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside class="fixed top-0 left-0 z-40 h-screen w-[260px] bg-bg-secondary border-r border-border flex flex-col select-none">
|
||||
<!-- Mobile overlay backdrop -->
|
||||
{#if open}
|
||||
<button
|
||||
class="fixed inset-0 z-30 bg-black/50 md:hidden"
|
||||
onclick={onclose}
|
||||
aria-label="Close sidebar"
|
||||
tabindex="-1"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<aside class="fixed top-0 left-0 z-40 h-screen w-[260px] bg-bg-secondary border-r border-border flex flex-col select-none transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'} md:translate-x-0">
|
||||
<!-- Workspace header -->
|
||||
<div class="flex items-center gap-2.5 h-14 px-4 border-b border-border flex-shrink-0">
|
||||
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-purple to-[#06b6d4] flex items-center justify-center">
|
||||
@@ -91,25 +107,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="px-3 py-2.5 flex-shrink-0">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-bg-tertiary rounded text-text-secondary text-xs hover:bg-bg-input transition-colors"
|
||||
onclick={() => goto('/conversations')}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Search messages
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable nav -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 pb-3">
|
||||
<!-- Dashboard -->
|
||||
<a
|
||||
href="/"
|
||||
class="sidebar-item mb-1 {isActive('/') && $page.url.pathname === '/' ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
@@ -117,15 +121,16 @@
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<!-- Conversations -->
|
||||
<!-- Search -->
|
||||
<a
|
||||
href="/conversations"
|
||||
class="sidebar-item mb-3 {isActive('/conversations') ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Conversations
|
||||
Search
|
||||
</a>
|
||||
|
||||
<!-- Channels section -->
|
||||
@@ -161,6 +166,7 @@
|
||||
<a
|
||||
href="/channels/{ch.name}"
|
||||
class="sidebar-item {isActive('/channels/' + ch.name) ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
{#if ch.name.startsWith('my-agents-')}
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
@@ -209,6 +215,7 @@
|
||||
<a
|
||||
href="/dm/{agent.name}"
|
||||
class="sidebar-item {isActive('/dm/' + agent.name) ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<span class="relative flex-shrink-0">
|
||||
<span class="w-5 h-5 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
|
||||
@@ -251,6 +258,7 @@
|
||||
<a
|
||||
href={link.href}
|
||||
class="sidebar-item {isActive(link.href) ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
{#if link.label === 'Agents'}
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
@@ -268,6 +276,7 @@
|
||||
<a
|
||||
href="/dead-letters"
|
||||
class="sidebar-item {isActive('/dead-letters') && $page.url.pathname === '/dead-letters' ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z" />
|
||||
@@ -280,6 +289,7 @@
|
||||
<a
|
||||
href="/dead-letters/webhooks"
|
||||
class="sidebar-item {isActive('/dead-letters/webhooks') ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { activeThread, closeThread } from '$lib/stores/thread';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let conversation = $state<any>(null);
|
||||
let threadMessages = $state<any[]>([]);
|
||||
@@ -162,7 +163,7 @@
|
||||
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</p>
|
||||
<div class="text-xs text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
let { items = [], title = '', linkPrefix = '', nameField = 'name', displayField = '' }:
|
||||
{ items: any[]; title: string; linkPrefix?: string; nameField?: string; displayField?: string } = $props();
|
||||
|
||||
let maxCount = $derived(items.length > 0 ? Math.max(...items.map(i => i.count), 1) : 1);
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-4 py-2.5 border-b border-border">
|
||||
<h3 class="font-semibold text-xs text-text-primary font-display">{title}</h3>
|
||||
</div>
|
||||
{#if items.length === 0}
|
||||
<div class="p-4 text-xs text-text-secondary text-center">No data</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#each items as item, i}
|
||||
<a
|
||||
href="{linkPrefix}{item[nameField]}"
|
||||
class="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-tertiary/50 transition-colors"
|
||||
>
|
||||
<span class="text-xs font-bold text-text-secondary w-5 text-right">{i + 1}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-text-primary truncate">
|
||||
{displayField && item[displayField] ? item[displayField] : item[nameField]}
|
||||
</p>
|
||||
{#if displayField && item[displayField] && item[displayField] !== item[nameField]}
|
||||
<p class="text-[10px] text-text-secondary font-mono">@{item[nameField]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="w-20 h-1.5 rounded-full bg-bg-tertiary overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-accent-blue"
|
||||
style="width: {(item.count / maxCount) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-text-secondary w-8 text-right">{item.count}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { agents as agentsApi, channels as channelsApi } from '$lib/api/client';
|
||||
|
||||
export type EntityInfo = {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
exists: boolean;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export const agentEntities = writable<Map<string, EntityInfo>>(new Map());
|
||||
export const channelEntities = writable<Map<string, EntityInfo>>(new Map());
|
||||
export const entitiesLoaded = writable(false);
|
||||
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
export async function loadEntities(): Promise<void> {
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = _loadEntities();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
async function _loadEntities() {
|
||||
try {
|
||||
const [agRes, chRes] = await Promise.all([
|
||||
agentsApi.list(),
|
||||
channelsApi.list()
|
||||
]);
|
||||
|
||||
const agMap = new Map<string, EntityInfo>();
|
||||
for (const a of agRes.agents ?? []) {
|
||||
agMap.set(a.name, {
|
||||
name: a.name,
|
||||
display_name: a.display_name,
|
||||
exists: true,
|
||||
deleted: a.status === 'inactive' || a.status === 'deleted'
|
||||
});
|
||||
}
|
||||
agentEntities.set(agMap);
|
||||
|
||||
const chMap = new Map<string, EntityInfo>();
|
||||
for (const c of chRes.channels ?? []) {
|
||||
chMap.set(c.name, {
|
||||
name: c.name,
|
||||
exists: true,
|
||||
deleted: false
|
||||
});
|
||||
}
|
||||
channelEntities.set(chMap);
|
||||
entitiesLoaded.set(true);
|
||||
} catch {
|
||||
// silently fail — mentions will render as plain text
|
||||
} finally {
|
||||
loadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function lookupAgent(name: string): EntityInfo | null {
|
||||
const map = get(agentEntities);
|
||||
return map.get(name) || null;
|
||||
}
|
||||
|
||||
export function lookupChannel(name: string): EntityInfo | null {
|
||||
const map = get(channelEntities);
|
||||
return map.get(name) || null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const MIN_SIZE = 12;
|
||||
const MAX_SIZE = 24;
|
||||
const STEP = 2;
|
||||
const DEFAULT_SIZE = 16;
|
||||
const STORAGE_KEY = 'synapbus-font-size';
|
||||
|
||||
function getInitialSize(): number {
|
||||
if (!browser) return DEFAULT_SIZE;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const val = parseInt(stored, 10);
|
||||
if (!isNaN(val) && val >= MIN_SIZE && val <= MAX_SIZE) return val;
|
||||
}
|
||||
return DEFAULT_SIZE;
|
||||
}
|
||||
|
||||
export const fontSize = writable<number>(getInitialSize());
|
||||
|
||||
export function applyFontSize(size: number) {
|
||||
if (browser) {
|
||||
document.documentElement.style.fontSize = `${size}px`;
|
||||
localStorage.setItem(STORAGE_KEY, String(size));
|
||||
}
|
||||
}
|
||||
|
||||
export function increaseFontSize() {
|
||||
fontSize.update((s) => {
|
||||
const next = Math.min(s + STEP, MAX_SIZE);
|
||||
applyFontSize(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function decreaseFontSize() {
|
||||
fontSize.update((s) => {
|
||||
const next = Math.max(s - STEP, MIN_SIZE);
|
||||
applyFontSize(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export { MIN_SIZE, MAX_SIZE };
|
||||
@@ -5,6 +5,8 @@
|
||||
import { checkAuth, user, loading } from '$lib/stores/auth';
|
||||
import { SSEClient } from '$lib/api/sse';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import { fontSize, applyFontSize } from '$lib/stores/fontSize';
|
||||
import { version as versionApi } from '$lib/api/client';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import ThreadPanel from '$lib/components/ThreadPanel.svelte';
|
||||
@@ -13,6 +15,9 @@
|
||||
let sseClient: SSEClient | null = $state(null);
|
||||
let sseUnsubscribe: (() => void) | null = $state(null);
|
||||
let initialized = $state(false);
|
||||
let sidebarOpen = $state(false);
|
||||
let versionStr = $state('');
|
||||
let repoUrl = $state('https://github.com/synapbus/synapbus');
|
||||
|
||||
let isLoginPage = $derived($page.url.pathname === '/login');
|
||||
|
||||
@@ -21,8 +26,8 @@
|
||||
return client.onEvent((event) => {
|
||||
if (event.type === 'new_message') {
|
||||
const d = event.data;
|
||||
if (d.channel_name) {
|
||||
notifications.incrementUnread('channel', d.channel_name);
|
||||
if (d.channel) {
|
||||
notifications.incrementUnread('channel', d.channel);
|
||||
} else if (d.from_agent) {
|
||||
notifications.incrementUnread('dm', d.from_agent);
|
||||
}
|
||||
@@ -34,9 +39,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Apply font size on mount
|
||||
$effect(() => {
|
||||
applyFontSize($fontSize);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
// Load version
|
||||
versionApi.get().then((v) => {
|
||||
versionStr = v.version;
|
||||
repoUrl = v.repo || repoUrl;
|
||||
}).catch(() => { versionStr = 'dev'; });
|
||||
|
||||
// Register service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
checkAuth().then((authenticated) => {
|
||||
if (!authenticated && !isLoginPage) {
|
||||
goto(`/login?return=${encodeURIComponent($page.url.pathname)}`);
|
||||
@@ -50,7 +71,11 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($user && sseClient && !sseClient.connected) {
|
||||
if ($user && !sseClient) {
|
||||
sseClient = new SSEClient();
|
||||
sseClient.connect();
|
||||
sseUnsubscribe = setupNotifications(sseClient);
|
||||
} else if ($user && sseClient && !sseClient.connected) {
|
||||
sseClient.connect();
|
||||
}
|
||||
});
|
||||
@@ -75,12 +100,21 @@
|
||||
{@render children()}
|
||||
{:else if $user}
|
||||
<div class="h-screen flex overflow-hidden bg-bg-primary">
|
||||
<Sidebar />
|
||||
<div class="ml-[260px] flex-1 flex flex-col min-w-0">
|
||||
<Header />
|
||||
<Sidebar open={sidebarOpen} onclose={() => (sidebarOpen = false)} />
|
||||
<div class="md:ml-[260px] flex-1 flex flex-col min-w-0 overflow-x-hidden">
|
||||
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
|
||||
<main class="flex-1 overflow-y-auto flex flex-col">
|
||||
{@render children()}
|
||||
</main>
|
||||
<!-- Version Footer -->
|
||||
{#if versionStr}
|
||||
<footer class="px-5 py-2 border-t border-border flex items-center justify-between text-[10px] text-text-secondary flex-shrink-0">
|
||||
<span>SynapBus</span>
|
||||
<a href="{repoUrl}" target="_blank" rel="noopener" class="hover:text-text-primary transition-colors">
|
||||
{versionStr}
|
||||
</a>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
<ThreadPanel />
|
||||
</div>
|
||||
|
||||
+117
-19
@@ -1,28 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { messages as messagesApi, conversations as convsApi, agents as agentsApi } from '$lib/api/client';
|
||||
import { analytics, messages as messagesApi, conversations as convsApi, agents as agentsApi } from '$lib/api/client';
|
||||
import MessageList from '$lib/components/MessageList.svelte';
|
||||
import ComposeForm from '$lib/components/ComposeForm.svelte';
|
||||
import AnalyticsChart from '$lib/components/AnalyticsChart.svelte';
|
||||
import TopList from '$lib/components/TopList.svelte';
|
||||
|
||||
let recentMessages = $state<any[]>([]);
|
||||
let recentConversations = $state<any[]>([]);
|
||||
let agentCount = $state(0);
|
||||
let agentTypeMap = $state<Record<string, string>>({});
|
||||
let loadingData = $state(true);
|
||||
let showCompose = $state(false);
|
||||
|
||||
// Analytics state
|
||||
let span = $state('24h');
|
||||
let timelineBuckets = $state<{ time: string; count: number }[]>([]);
|
||||
let topAgents = $state<any[]>([]);
|
||||
let topChannels = $state<any[]>([]);
|
||||
let summary = $state<{ total_agents: number; total_channels: number; total_messages: number }>({
|
||||
total_agents: 0,
|
||||
total_channels: 0,
|
||||
total_messages: 0
|
||||
});
|
||||
let loadingAnalytics = $state(false);
|
||||
|
||||
const spans = [
|
||||
{ value: '1h', label: '1H' },
|
||||
{ value: '4h', label: '4H' },
|
||||
{ value: '24h', label: '24H' },
|
||||
{ value: '7d', label: '7D' },
|
||||
{ value: '30d', label: '1M' }
|
||||
];
|
||||
|
||||
async function loadData() {
|
||||
loadingData = true;
|
||||
try {
|
||||
const [msgRes, convRes, agentRes] = await Promise.all([
|
||||
const [msgRes, convRes, agentRes, summaryRes] = await Promise.all([
|
||||
messagesApi.list({ limit: 20 }),
|
||||
convsApi.list(),
|
||||
agentsApi.list()
|
||||
agentsApi.list(),
|
||||
analytics.summary()
|
||||
]);
|
||||
recentMessages = msgRes.messages;
|
||||
recentConversations = convRes.conversations;
|
||||
agentCount = agentRes.agents.length;
|
||||
summary = summaryRes;
|
||||
const typeMap: Record<string, string> = {};
|
||||
for (const agent of agentRes.agents) {
|
||||
for (const agent of (agentRes.agents ?? [])) {
|
||||
typeMap[agent.name] = agent.type;
|
||||
}
|
||||
agentTypeMap = typeMap;
|
||||
@@ -31,6 +53,33 @@
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
await loadAnalytics();
|
||||
}
|
||||
|
||||
async function loadAnalytics() {
|
||||
loadingAnalytics = true;
|
||||
try {
|
||||
const [timelineRes, agentsRes, channelsRes] = await Promise.all([
|
||||
analytics.timeline(span),
|
||||
analytics.topAgents(span),
|
||||
analytics.topChannels(span)
|
||||
]);
|
||||
timelineBuckets = timelineRes.buckets ?? [];
|
||||
topAgents = agentsRes.agents ?? [];
|
||||
topChannels = channelsRes.channels ?? [];
|
||||
} catch {
|
||||
// Analytics may not be available yet
|
||||
timelineBuckets = [];
|
||||
topAgents = [];
|
||||
topChannels = [];
|
||||
} finally {
|
||||
loadingAnalytics = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchSpan(s: string) {
|
||||
span = s;
|
||||
loadAnalytics();
|
||||
}
|
||||
|
||||
let _initialized = $state(false);
|
||||
@@ -42,7 +91,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="p-3 md:p-5 max-w-6xl w-full">
|
||||
<!-- Top bar -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
@@ -70,20 +119,69 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-5">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-text-secondary mb-1">Messages</p>
|
||||
<p class="text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : recentMessages.length}</p>
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-4 gap-2 md:gap-3 mb-5">
|
||||
<div class="card p-3 md:p-4">
|
||||
<p class="text-[10px] md:text-xs text-text-secondary mb-1">Messages</p>
|
||||
<p class="text-lg md:text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : summary.total_messages}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-text-secondary mb-1">Conversations</p>
|
||||
<p class="text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : recentConversations.length}</p>
|
||||
<div class="card p-3 md:p-4">
|
||||
<p class="text-[10px] md:text-xs text-text-secondary mb-1">Agents</p>
|
||||
<p class="text-lg md:text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : summary.total_agents}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-text-secondary mb-1">Agents</p>
|
||||
<p class="text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : agentCount}</p>
|
||||
<div class="card p-3 md:p-4">
|
||||
<p class="text-[10px] md:text-xs text-text-secondary mb-1">Channels</p>
|
||||
<p class="text-lg md:text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : summary.total_channels}</p>
|
||||
</div>
|
||||
<div class="card p-3 md:p-4">
|
||||
<p class="text-[10px] md:text-xs text-text-secondary mb-1">Convos</p>
|
||||
<p class="text-lg md:text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : recentConversations.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Activity Chart -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Message Activity</h2>
|
||||
<div class="flex gap-1">
|
||||
{#each spans as s}
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs rounded transition-colors {span === s.value
|
||||
? 'bg-accent-blue text-white font-semibold'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'}"
|
||||
onclick={() => switchSpan(s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{#if loadingAnalytics}
|
||||
<div class="flex items-center justify-center h-[160px]">
|
||||
<div class="w-6 h-6 border-2 border-border-active border-t-accent-blue rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<AnalyticsChart buckets={timelineBuckets} {span} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top 5 Lists -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-5">
|
||||
<TopList
|
||||
title="Top 5 Agents"
|
||||
items={topAgents}
|
||||
linkPrefix="/agents/"
|
||||
nameField="name"
|
||||
displayField="display_name"
|
||||
/>
|
||||
<TopList
|
||||
title="Top 5 Channels"
|
||||
items={topChannels}
|
||||
linkPrefix="/channels/"
|
||||
nameField="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Recent Conversations -->
|
||||
@@ -141,7 +239,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !loadingData && recentMessages.length === 0 && agentCount === 0}
|
||||
{#if !loadingData && recentMessages.length === 0 && summary.total_agents === 0}
|
||||
<div class="card p-10 text-center mt-5">
|
||||
<div class="w-12 h-12 mx-auto rounded-2xl bg-gradient-to-br from-accent-purple/20 to-[#06b6d4]/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none">
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
let deleting = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
// Inline name editing
|
||||
let editingName = $state(false);
|
||||
let editNameValue = $state('');
|
||||
let savingName = $state(false);
|
||||
let nameError = $state('');
|
||||
|
||||
// Access Rights state
|
||||
let allowedChannels = $state('');
|
||||
let readOnly = $state(false);
|
||||
@@ -80,6 +86,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startEditName() {
|
||||
editNameValue = agent.display_name || agent.name;
|
||||
editingName = true;
|
||||
nameError = '';
|
||||
}
|
||||
|
||||
async function saveDisplayName() {
|
||||
if (!editNameValue.trim()) {
|
||||
nameError = 'Name cannot be empty';
|
||||
return;
|
||||
}
|
||||
savingName = true;
|
||||
nameError = '';
|
||||
try {
|
||||
const res = await agentsApi.update(agentName, { display_name: editNameValue.trim() });
|
||||
agent = res.agent;
|
||||
editingName = false;
|
||||
} catch (err: any) {
|
||||
nameError = err.message || 'Failed to update name';
|
||||
} finally {
|
||||
savingName = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNameKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveDisplayName();
|
||||
} else if (e.key === 'Escape') {
|
||||
editingName = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAccess() {
|
||||
savingAccess = true;
|
||||
accessError = '';
|
||||
@@ -135,10 +174,36 @@
|
||||
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-text-primary font-display">{agent.display_name || agent.name}</h1>
|
||||
{#if agent.display_name}
|
||||
<p class="text-xs text-text-secondary font-mono">@{agent.name}</p>
|
||||
{#if editingName}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="input text-lg font-bold py-0.5 px-2 w-48"
|
||||
bind:value={editNameValue}
|
||||
onkeydown={handleNameKeydown}
|
||||
onblur={saveDisplayName}
|
||||
autofocus
|
||||
/>
|
||||
{#if savingName}
|
||||
<span class="text-xs text-text-secondary">Saving...</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if nameError}
|
||||
<p class="text-xs text-accent-red mt-0.5">{nameError}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<h1
|
||||
class="text-lg font-bold text-text-primary font-display cursor-pointer hover:text-accent-blue transition-colors"
|
||||
onclick={startEditName}
|
||||
title="Click to edit display name"
|
||||
>
|
||||
{agent.display_name || agent.name}
|
||||
<svg class="w-3.5 h-3.5 inline-block ml-1 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</h1>
|
||||
{/if}
|
||||
<p class="text-xs text-text-secondary font-mono">@{agent.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user