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
|
||||
|
||||
@@ -98,6 +98,13 @@ make lint # Run linters
|
||||
- modernc.org/sqlite (pure Go), migration 009_webhooks.sql (003-webhooks-k8s-runner)
|
||||
- Go 1.25+ (per go.mod) + mark3labs/mcp-go (MCP tools), go-chi/chi (HTTP), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors) (004-embeddings-retention-inbox)
|
||||
- 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)
|
||||
- Go 1.25+ (backend), Svelte 5 + Tailwind (frontend) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), spf13/cobra (CLI) (009-attachments-threads)
|
||||
- SQLite (modernc.org/sqlite, pure Go) + content-addressable filesystem (SHA-256) (009-attachments-threads)
|
||||
|
||||
## 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)
|
||||
|
||||
+2
-3
@@ -18,9 +18,8 @@ COPY --from=web-builder /app/web/build internal/web/dist/
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /synapbus ./cmd/synapbus/
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM scratch
|
||||
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=go-builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates tzdata && touch /.dockerenv
|
||||
COPY --from=go-builder /synapbus /synapbus
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Autonomous Implementation Summary: Attachments & Threads Enhancement
|
||||
|
||||
**Branch**: `009-attachments-threads`
|
||||
**Date**: 2026-03-17
|
||||
**Status**: Complete
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Attachments (Web UI + MCP + Admin CLI)
|
||||
|
||||
1. **Web UI file upload**: Users can attach files (images, PDFs, text files) when composing messages via a paperclip button in the compose form. Uploaded files show as chips with remove buttons.
|
||||
|
||||
2. **Attachment display**: Image attachments render as thumbnails (200x200px max) inline with messages. Clicking opens a fullscreen overlay with close (Escape, click-outside, X button) and download. Non-image files show as file icons with filename, size, and download link.
|
||||
|
||||
3. **MCP agent support**: Agents can attach files by uploading via `upload_attachment` action and passing hashes in the new `attachments` parameter on `send_message`. Tool descriptions updated to document the workflow.
|
||||
|
||||
4. **File type validation**: Upload restricted to images (jpg, png, gif, webp, svg), PDFs, and text files (txt, md, csv, json, xml, yaml, log). Invalid types rejected with clear error.
|
||||
|
||||
5. **Admin backup/restore**: `synapbus attachments backup --output path.tar.gz` and `synapbus attachments restore --input path.tar.gz` commands. Restore is dedup-safe (skips existing files).
|
||||
|
||||
### Threads
|
||||
|
||||
1. **Thread visibility**: Messages with replies show an always-visible "N replies" badge with a chat bubble icon. Clicking opens the thread panel.
|
||||
|
||||
2. **Reply count in API**: All message API responses now include `reply_count` (batch-loaded via efficient GROUP BY query).
|
||||
|
||||
3. **MCP threading**: `reply_to` parameter description updated to clearly guide agents on threading behavior. Thread context visible in MCP responses.
|
||||
|
||||
4. **Attachment display in threads**: Thread panel also renders attachment thumbnails/file icons.
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Backend (Go)
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `internal/messaging/types.go` | Added `AttachmentInfo` struct, `ReplyCount`, `Attachments` fields to `Message` |
|
||||
| `internal/messaging/options.go` | Added `Attachments []string` to `SendOptions` |
|
||||
| `internal/messaging/store.go` | Added `GetReplyCounts` batch query method + interface |
|
||||
| `internal/messaging/service.go` | Added `AttachmentLinker` interface, `EnrichMessages`, attachment linking in `SendMessage` |
|
||||
| `internal/attachments/model.go` | Added `ErrUnsupportedType` error |
|
||||
| `internal/attachments/mime.go` | Added `IsAllowedType` function |
|
||||
| `internal/attachments/service.go` | Added file type validation in `Upload` |
|
||||
| `internal/api/messages_handler.go` | Accept `attachments[]` in send, `EnrichMessages` in all handlers |
|
||||
| `internal/mcp/tools_hybrid.go` | Added `attachments` param to `send_message`, updated descriptions, enrich responses |
|
||||
| `internal/mcp/bridge.go` | Handle attachments in `callSendMessage`, `callSendChannelMessage` |
|
||||
| `internal/channels/service.go` | Added `attachments` param to `BroadcastMessage` |
|
||||
| `internal/actions/registry.go` | Updated `upload_attachment` description |
|
||||
| `cmd/synapbus/main.go` | Added `attachmentLinkerAdapter`, wired into messaging service |
|
||||
| `cmd/synapbus/admin.go` | Added `backup` and `restore` subcommands |
|
||||
|
||||
### Frontend (Svelte)
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `web/src/lib/api/client.ts` | Added `attachments.upload()`, `attachments` param in `messages.send()` |
|
||||
| `web/src/lib/components/ComposeForm.svelte` | Attachment upload button, file picker, preview chips |
|
||||
| `web/src/lib/components/AttachmentPreview.svelte` | **NEW** — thumbnail + fullscreen overlay component |
|
||||
| `web/src/lib/components/MessageList.svelte` | Attachment display, thread reply count badges |
|
||||
| `web/src/lib/components/ThreadPanel.svelte` | Attachment display in thread messages |
|
||||
|
||||
### Tests
|
||||
| File | New Tests |
|
||||
|------|-----------|
|
||||
| `internal/messaging/store_test.go` | `TestSQLiteMessageStore_GetReplyCounts` (4 subtests) |
|
||||
| `internal/messaging/service_test.go` | `TestMessagingService_EnrichMessages` (3 subtests) |
|
||||
| `internal/attachments/mime_test.go` | `TestIsAllowedType` (12 cases) |
|
||||
| `internal/attachments/service_test.go` | `TestService_Upload_FileTypeValidation` (5 cases) |
|
||||
| `internal/channels/service_test.go` | Updated 13 call sites for new `BroadcastMessage` signature |
|
||||
|
||||
## Test Results
|
||||
|
||||
- **Go tests**: 25 packages, all pass, 0 failures
|
||||
- **Integration tests**: 9 E2E tests, all pass
|
||||
- **New tests**: 24 test cases added, all pass
|
||||
- **Web build**: Svelte SPA builds successfully
|
||||
- **Binary build**: 90MB arm64 binary compiles cleanly
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
1. **No circular dependencies**: Used `AttachmentLinker` interface + adapter pattern to avoid messaging->attachments import
|
||||
2. **Batch loading**: Reply counts loaded via single GROUP BY query; attachments loaded per-message (acceptable for LAN scale)
|
||||
3. **Client-side thumbnails**: CSS-only resizing (no server-side image processing, preserves zero-CGO constraint)
|
||||
4. **Zero new migrations**: Leveraged existing `reply_to` column (migration 007) and `attachments` table (migration 001)
|
||||
5. **Zero new dependencies**: All using Go stdlib + existing libraries
|
||||
|
||||
## Constitution Compliance
|
||||
|
||||
All 10 principles satisfied:
|
||||
- I. Local-First: No external dependencies added
|
||||
- II. MCP-Native: Agent features use MCP tools exclusively
|
||||
- III. Pure Go, Zero CGO: stdlib archive/tar + compress/gzip for backup
|
||||
- X. Web UI First-Class: Full attachment and thread UI experience
|
||||
+264
-5
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
@@ -17,7 +21,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 +312,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{
|
||||
@@ -561,7 +589,54 @@ func addAdminCommands(rootCmd *cobra.Command) {
|
||||
channelsShowCmd.Flags().StringVar(&channelsShowName, "name", "", "Channel name")
|
||||
channelsShowCmd.MarkFlagRequired("name")
|
||||
|
||||
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd)
|
||||
var (
|
||||
channelsCreateName string
|
||||
channelsCreateDesc string
|
||||
)
|
||||
channelsCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new channel",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
resp, err := adminRequest("channels.create", map[string]string{
|
||||
"name": channelsCreateName,
|
||||
"description": channelsCreateDesc,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(resp["data"])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
channelsCreateCmd.Flags().StringVar(&channelsCreateName, "name", "", "Channel name")
|
||||
channelsCreateCmd.Flags().StringVar(&channelsCreateDesc, "description", "", "Channel description")
|
||||
channelsCreateCmd.MarkFlagRequired("name")
|
||||
|
||||
var (
|
||||
channelsJoinChannel string
|
||||
channelsJoinAgent string
|
||||
)
|
||||
channelsJoinCmd := &cobra.Command{
|
||||
Use: "join",
|
||||
Short: "Add an agent to a channel",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
resp, err := adminRequest("channels.join", map[string]string{
|
||||
"channel": channelsJoinChannel,
|
||||
"agent": channelsJoinAgent,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(resp["data"])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
channelsJoinCmd.Flags().StringVar(&channelsJoinChannel, "channel", "", "Channel name")
|
||||
channelsJoinCmd.Flags().StringVar(&channelsJoinAgent, "agent", "", "Agent name")
|
||||
channelsJoinCmd.MarkFlagRequired("channel")
|
||||
channelsJoinCmd.MarkFlagRequired("agent")
|
||||
|
||||
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd, channelsCreateCmd, channelsJoinCmd)
|
||||
|
||||
// ----- conversations commands -----
|
||||
conversationsCmd := &cobra.Command{
|
||||
@@ -916,10 +991,51 @@ func addAdminCommands(rootCmd *cobra.Command) {
|
||||
},
|
||||
}
|
||||
|
||||
attachmentsCmd.AddCommand(attachmentsGCCmd)
|
||||
var attachmentsBackupOutput string
|
||||
var attachmentsBackupDataDir string
|
||||
attachmentsBackupCmd := &cobra.Command{
|
||||
Use: "backup",
|
||||
Short: "Create a tar.gz backup of all attachments (no server required)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
attachDir := filepath.Join(attachmentsBackupDataDir, "attachments")
|
||||
if _, err := os.Stat(attachDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("attachments directory does not exist: %s", attachDir)
|
||||
}
|
||||
fileCount, totalSize, err := backupAttachments(attachDir, attachmentsBackupOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Backup complete: %d files, %s total, written to %s\n", fileCount, formatBytes(totalSize), attachmentsBackupOutput)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
attachmentsBackupCmd.Flags().StringVar(&attachmentsBackupOutput, "output", "", "Output path for the tar.gz archive")
|
||||
attachmentsBackupCmd.Flags().StringVar(&attachmentsBackupDataDir, "data", "./data", "Data directory")
|
||||
attachmentsBackupCmd.MarkFlagRequired("output")
|
||||
|
||||
var attachmentsRestoreInput string
|
||||
var attachmentsRestoreDataDir string
|
||||
attachmentsRestoreCmd := &cobra.Command{
|
||||
Use: "restore",
|
||||
Short: "Restore attachments from a tar.gz backup (no server required)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
attachDir := filepath.Join(attachmentsRestoreDataDir, "attachments")
|
||||
restored, skipped, err := restoreAttachments(attachDir, attachmentsRestoreInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Restore complete: %d files restored, %d files skipped (already exist)\n", restored, skipped)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
attachmentsRestoreCmd.Flags().StringVar(&attachmentsRestoreInput, "input", "", "Input path for the tar.gz archive")
|
||||
attachmentsRestoreCmd.Flags().StringVar(&attachmentsRestoreDataDir, "data", "./data", "Data directory")
|
||||
attachmentsRestoreCmd.MarkFlagRequired("input")
|
||||
|
||||
attachmentsCmd.AddCommand(attachmentsGCCmd, attachmentsBackupCmd, attachmentsRestoreCmd)
|
||||
|
||||
// ----- 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)
|
||||
}
|
||||
@@ -941,3 +1057,146 @@ func toTableRows(data []map[string]string, headerMap map[string]string) []map[st
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// backupAttachments creates a tar.gz archive of the attachments directory.
|
||||
// Returns the number of files archived and total bytes of file content.
|
||||
func backupAttachments(attachmentsDir, outputPath string) (int, int64, error) {
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
gzw := gzip.NewWriter(outFile)
|
||||
defer gzw.Close()
|
||||
|
||||
tw := tar.NewWriter(gzw)
|
||||
defer tw.Close()
|
||||
|
||||
var fileCount int
|
||||
var totalSize int64
|
||||
|
||||
err = filepath.Walk(attachmentsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Skip directories — tar entries for files include the path.
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(attachmentsDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relative path: %w", err)
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("file info header: %w", err)
|
||||
}
|
||||
header.Name = relPath
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(tw, f); err != nil {
|
||||
return fmt.Errorf("copy file: %w", err)
|
||||
}
|
||||
|
||||
fileCount++
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
|
||||
return fileCount, totalSize, err
|
||||
}
|
||||
|
||||
// restoreAttachments extracts a tar.gz archive into the attachments directory.
|
||||
// Files that already exist on disk are skipped. Returns (restored, skipped) counts.
|
||||
func restoreAttachments(attachmentsDir, inputPath string) (int, int, error) {
|
||||
inFile, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("open input file: %w", err)
|
||||
}
|
||||
defer inFile.Close()
|
||||
|
||||
gzr, err := gzip.NewReader(inFile)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
|
||||
var restored, skipped int
|
||||
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return restored, skipped, fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
|
||||
// Only handle regular files.
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize: reject absolute paths and path traversal.
|
||||
cleanName := filepath.Clean(header.Name)
|
||||
if filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, "..") {
|
||||
return restored, skipped, fmt.Errorf("invalid path in archive: %s", header.Name)
|
||||
}
|
||||
|
||||
destPath := filepath.Join(attachmentsDir, cleanName)
|
||||
|
||||
// Skip if already exists (content-addressable, so same hash = same content).
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return restored, skipped, fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return restored, skipped, fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(outFile, tr); err != nil {
|
||||
outFile.Close()
|
||||
return restored, skipped, fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
outFile.Close()
|
||||
|
||||
restored++
|
||||
}
|
||||
|
||||
return restored, skipped, nil
|
||||
}
|
||||
|
||||
// formatBytes returns a human-readable byte count string.
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
@@ -196,6 +196,85 @@ func TestK8sRegisterOptionalFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsCreateCommandRegistered(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
cmd := findSubcommand(root, "channels", "create")
|
||||
if cmd == nil {
|
||||
t.Fatal("channels create command not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsCreateRequiredFlags(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
cmd := findSubcommand(root, "channels", "create")
|
||||
if cmd == nil {
|
||||
t.Fatal("channels create command not found")
|
||||
}
|
||||
|
||||
// --name is required
|
||||
f := cmd.Flag("name")
|
||||
if f == nil {
|
||||
t.Fatal("flag --name not found on channels create")
|
||||
}
|
||||
ann := f.Annotations
|
||||
if ann == nil {
|
||||
t.Fatal("flag --name should be required")
|
||||
}
|
||||
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
|
||||
t.Fatal("flag --name should be required")
|
||||
}
|
||||
|
||||
// --description is optional
|
||||
df := cmd.Flag("description")
|
||||
if df == nil {
|
||||
t.Fatal("flag --description not found on channels create")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsJoinCommandRegistered(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
cmd := findSubcommand(root, "channels", "join")
|
||||
if cmd == nil {
|
||||
t.Fatal("channels join command not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsJoinRequiredFlags(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
cmd := findSubcommand(root, "channels", "join")
|
||||
if cmd == nil {
|
||||
t.Fatal("channels join command not found")
|
||||
}
|
||||
|
||||
requiredFlags := []string{"channel", "agent"}
|
||||
for _, flag := range requiredFlags {
|
||||
f := cmd.Flag(flag)
|
||||
if f == nil {
|
||||
t.Errorf("flag --%s not found on channels join", flag)
|
||||
continue
|
||||
}
|
||||
ann := f.Annotations
|
||||
if ann == nil {
|
||||
t.Errorf("flag --%s should be required", flag)
|
||||
continue
|
||||
}
|
||||
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
|
||||
t.Errorf("flag --%s should be required", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSocketPath(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
f := root.PersistentFlags().Lookup("socket")
|
||||
if f == nil {
|
||||
t.Fatal("--socket persistent flag not found")
|
||||
}
|
||||
if f.DefValue != "/tmp/synapbus.sock" {
|
||||
t.Errorf("default socket path = %q, want %q", f.DefValue, "/tmp/synapbus.sock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingCommandsStillPresent(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
|
||||
|
||||
+170
-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)
|
||||
@@ -272,6 +281,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
attachmentStore := attachments.NewSQLiteStore(db.DB, slog.Default())
|
||||
attachmentService := attachments.NewService(attachmentStore, cas, slog.Default())
|
||||
msgService.SetAttachmentLinker(&attachmentLinkerAdapter{svc: attachmentService})
|
||||
slog.Info("attachment service initialized", "dir", attachmentsDir)
|
||||
|
||||
// Initialize auth subsystem
|
||||
@@ -299,6 +309,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 +406,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 +488,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 +536,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 +577,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 +586,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 +621,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 +714,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 +815,55 @@ 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
|
||||
}
|
||||
|
||||
// attachmentLinkerAdapter adapts attachments.Service to messaging.AttachmentLinker.
|
||||
type attachmentLinkerAdapter struct {
|
||||
svc *attachments.Service
|
||||
}
|
||||
|
||||
func (a *attachmentLinkerAdapter) AttachToMessage(ctx context.Context, hash string, messageID int64) error {
|
||||
return a.svc.AttachToMessage(ctx, hash, messageID)
|
||||
}
|
||||
|
||||
func (a *attachmentLinkerAdapter) GetByMessageID(ctx context.Context, messageID int64) ([]messaging.AttachmentInfo, error) {
|
||||
atts, err := a.svc.GetByMessageID(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]messaging.AttachmentInfo, len(atts))
|
||||
for i, att := range atts {
|
||||
results[i] = messaging.AttachmentInfo{
|
||||
Hash: att.Hash,
|
||||
OriginalFilename: att.OriginalFilename,
|
||||
Size: att.Size,
|
||||
MIMEType: att.MIMEType,
|
||||
IsImage: attachments.IsImageType(att.MIMEType),
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// agentListerAdapter adapts agents.AgentService to auth.AgentLister.
|
||||
type agentListerAdapter struct {
|
||||
agentService *agents.AgentService
|
||||
@@ -755,6 +888,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 +950,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
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ spec:
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
@@ -56,6 +60,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: run
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
@@ -64,6 +70,10 @@ spec:
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: run
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -11,5 +11,8 @@ spec:
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
|
||||
nodePort: {{ .Values.service.nodePort }}
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "synapbus.selectorLabels" . | nindent 4 }}
|
||||
|
||||
@@ -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{
|
||||
@@ -424,7 +425,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "upload_attachment",
|
||||
Category: "attachments",
|
||||
Description: "Upload a file attachment. Content must be base64-encoded. Returns the SHA-256 hash for later retrieval. Max file size: 50MB.",
|
||||
Description: "Upload a file attachment. Content must be base64-encoded. Returns the SHA-256 hash for later retrieval. Upload first, then use the returned hash in send_message's attachments parameter to link it to a message. Max file size: 50MB.",
|
||||
Params: []Param{
|
||||
{Name: "content", Type: "string", Description: "Base64-encoded file content", Required: true},
|
||||
{Name: "filename", Type: "string", Description: "Original filename (optional, used for MIME detection and display)"},
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
@@ -138,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":
|
||||
@@ -162,6 +165,10 @@ func (s *AdminServer) dispatch(req Request) Response {
|
||||
return s.handleChannelsList(ctx)
|
||||
case "channels.show":
|
||||
return s.handleChannelsShow(ctx, req.Args)
|
||||
case "channels.create":
|
||||
return s.handleChannelsCreate(ctx, req.Args)
|
||||
case "channels.join":
|
||||
return s.handleChannelsJoin(ctx, req.Args)
|
||||
|
||||
// --- conversations ---
|
||||
case "conversations.list":
|
||||
@@ -441,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 {
|
||||
@@ -887,6 +923,78 @@ func (s *AdminServer) handleChannelsShow(ctx context.Context, args json.RawMessa
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *AdminServer) handleChannelsCreate(ctx context.Context, args json.RawMessage) Response {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
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"}
|
||||
}
|
||||
|
||||
ch, err := s.services.Channels.CreateChannel(ctx, channels.CreateChannelRequest{
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Type: "standard",
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
return Response{OK: false, Error: err.Error()}
|
||||
}
|
||||
|
||||
return Response{OK: true, Data: map[string]interface{}{
|
||||
"id": ch.ID,
|
||||
"name": ch.Name,
|
||||
"description": ch.Description,
|
||||
"type": ch.Type,
|
||||
"is_private": ch.IsPrivate,
|
||||
"created_by": ch.CreatedBy,
|
||||
"created_at": ch.CreatedAt.Format(time.RFC3339),
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *AdminServer) handleChannelsJoin(ctx context.Context, args json.RawMessage) Response {
|
||||
var p struct {
|
||||
Channel string `json:"channel"`
|
||||
Agent string `json:"agent"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return Response{OK: false, Error: "invalid args: " + err.Error()}
|
||||
}
|
||||
if p.Channel == "" {
|
||||
return Response{OK: false, Error: "channel is required"}
|
||||
}
|
||||
if p.Agent == "" {
|
||||
return Response{OK: false, Error: "agent is required"}
|
||||
}
|
||||
|
||||
ch, err := s.services.Channels.GetChannelByName(ctx, p.Channel)
|
||||
if err != nil {
|
||||
return Response{OK: false, Error: fmt.Sprintf("channel not found: %s", p.Channel)}
|
||||
}
|
||||
|
||||
// Check if already a member for status reporting
|
||||
isMember, _ := s.services.Channels.IsMember(ctx, ch.ID, p.Agent)
|
||||
|
||||
if err := s.services.Channels.JoinChannel(ctx, ch.ID, p.Agent); err != nil {
|
||||
return Response{OK: false, Error: err.Error()}
|
||||
}
|
||||
|
||||
status := "joined"
|
||||
if isMember {
|
||||
status = "already_member"
|
||||
}
|
||||
|
||||
return Response{OK: true, Data: map[string]interface{}{
|
||||
"channel": p.Channel,
|
||||
"agent": p.Agent,
|
||||
"status": status,
|
||||
}}
|
||||
}
|
||||
|
||||
// ---------- conversations handlers ----------
|
||||
|
||||
func (s *AdminServer) handleConversationsList(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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,8 @@ func (h *AttachmentsHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"empty file not allowed"}`, http.StatusBadRequest)
|
||||
case attachments.ErrFileTooLarge:
|
||||
http.Error(w, `{"error":"file exceeds maximum size of 50MB"}`, http.StatusRequestEntityTooLarge)
|
||||
case attachments.ErrUnsupportedType:
|
||||
http.Error(w, `{"error":"unsupported file type: only images, PDFs, and text files are allowed"}`, http.StatusBadRequest)
|
||||
default:
|
||||
h.logger.Error("upload attachment failed", "error", err)
|
||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,9 @@ func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Enrich messages with reply counts and attachments.
|
||||
h.msgService.EnrichMessages(r.Context(), paginated.Messages)
|
||||
|
||||
// Compute last_read_message_id across owned agents
|
||||
var lastReadMessageID int64
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -85,6 +86,8 @@ func (h *MessagesHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
allMessages = []*messaging.Message{}
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), allMessages)
|
||||
|
||||
sortMessagesByTime(allMessages)
|
||||
if len(allMessages) > limit {
|
||||
allMessages = allMessages[:limit]
|
||||
@@ -121,6 +124,8 @@ func (h *MessagesHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), []*messaging.Message{msg})
|
||||
|
||||
writeJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
@@ -229,6 +234,8 @@ func (h *MessagesHandler) GetConversation(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), messages)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"conversation": conv,
|
||||
"messages": messages,
|
||||
@@ -244,14 +251,15 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Body string `json:"body"`
|
||||
Priority int `json:"priority"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
ReplyTo *int64 `json:"reply_to,omitempty"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Body string `json:"body"`
|
||||
Priority int `json:"priority"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
ReplyTo *int64 `json:"reply_to,omitempty"`
|
||||
Attachments []string `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -306,6 +314,7 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
ConversationID: req.ConversationID,
|
||||
Subject: req.Subject,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Attachments: req.Attachments,
|
||||
}
|
||||
|
||||
msg, err := h.msgService.SendMessage(r.Context(), req.From, req.To, req.Body, opts)
|
||||
@@ -315,23 +324,10 @@ 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.
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), []*messaging.Message{msg})
|
||||
|
||||
writeJSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
@@ -414,9 +410,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
|
||||
@@ -428,6 +475,8 @@ func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request)
|
||||
allMessages = []*messaging.Message{}
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), allMessages)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"messages": allMessages,
|
||||
"query": query,
|
||||
@@ -468,6 +517,8 @@ func (h *MessagesHandler) GetReplies(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), replies)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"replies": replies,
|
||||
"total": len(replies),
|
||||
@@ -513,6 +564,8 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), msgs)
|
||||
|
||||
// Include last_read_message_id for the human agent's DM with the peer
|
||||
lastRead, _ := h.msgService.GetLastReadForDM(r.Context(), agentNames, peerAgent)
|
||||
|
||||
|
||||
@@ -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, 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")
|
||||
}
|
||||
}
|
||||
@@ -118,3 +118,27 @@ func DefaultFilename(mimeType string) string {
|
||||
func IsImageType(mimeType string) bool {
|
||||
return imageTypes[mimeType]
|
||||
}
|
||||
|
||||
// IsAllowedType returns true if the MIME type is allowed for upload.
|
||||
// Allowed: image/*, application/pdf, text/*.
|
||||
func IsAllowedType(mimeType string) bool {
|
||||
// Normalize: strip parameters like "; charset=utf-8".
|
||||
base := mimeType
|
||||
if idx := strings.Index(mimeType, ";"); idx >= 0 {
|
||||
base = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
if strings.HasPrefix(base, "image/") {
|
||||
return true
|
||||
}
|
||||
if base == "application/pdf" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(base, "text/") {
|
||||
return true
|
||||
}
|
||||
// Also allow JSON and XML which may be detected as application/*
|
||||
if base == "application/json" || base == "application/xml" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -154,6 +154,35 @@ func TestIsImageType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedType(t *testing.T) {
|
||||
tests := []struct {
|
||||
mimeType string
|
||||
want bool
|
||||
}{
|
||||
{"image/png", true},
|
||||
{"image/jpeg", true},
|
||||
{"image/gif", true},
|
||||
{"application/pdf", true},
|
||||
{"text/plain", true},
|
||||
{"text/csv", true},
|
||||
{"text/plain; charset=utf-8", true},
|
||||
{"application/json", true},
|
||||
{"application/octet-stream", false},
|
||||
{"application/zip", false},
|
||||
{"application/x-executable", false},
|
||||
{"video/mp4", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mimeType, func(t *testing.T) {
|
||||
got := IsAllowedType(tt.mimeType)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsAllowedType(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
||||
@@ -11,10 +11,11 @@ const MaxFileSize = 50 * 1024 * 1024 // 50 MB
|
||||
|
||||
// Sentinel errors.
|
||||
var (
|
||||
ErrNotFound = errors.New("attachment not found")
|
||||
ErrFileTooLarge = errors.New("file exceeds maximum size of 50MB")
|
||||
ErrEmptyFile = errors.New("empty file not allowed")
|
||||
ErrFileMissing = errors.New("attachment file missing from disk")
|
||||
ErrNotFound = errors.New("attachment not found")
|
||||
ErrFileTooLarge = errors.New("file exceeds maximum size of 50MB")
|
||||
ErrEmptyFile = errors.New("empty file not allowed")
|
||||
ErrFileMissing = errors.New("attachment file missing from disk")
|
||||
ErrUnsupportedType = errors.New("unsupported file type: only images (jpg, png, gif, webp, svg), PDFs, and text files are allowed")
|
||||
)
|
||||
|
||||
// Attachment represents the metadata for a stored file.
|
||||
|
||||
@@ -60,6 +60,11 @@ func (s *Service) Upload(ctx context.Context, req UploadRequest) (*UploadResult,
|
||||
mimeType = DetectMIMEType(sniffBuf, req.Filename)
|
||||
}
|
||||
|
||||
// Validate file type against allowlist.
|
||||
if !IsAllowedType(mimeType) {
|
||||
return nil, ErrUnsupportedType
|
||||
}
|
||||
|
||||
// Assign default filename if missing.
|
||||
filename := req.Filename
|
||||
if filename == "" {
|
||||
|
||||
@@ -202,6 +202,80 @@ func TestService_Dedup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Upload_FileTypeValidation(t *testing.T) {
|
||||
// PNG magic bytes.
|
||||
pngContent := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00}
|
||||
// PDF magic bytes.
|
||||
pdfContent := []byte("%PDF-1.4 some pdf content here")
|
||||
// Plain text content.
|
||||
textContent := []byte("just some plain text content")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
filename string
|
||||
mimeType string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid image upload",
|
||||
content: pngContent,
|
||||
filename: "photo.png",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid PDF upload",
|
||||
content: pdfContent,
|
||||
filename: "report.pdf",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid text file upload",
|
||||
content: textContent,
|
||||
filename: "notes.txt",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid type zip rejected",
|
||||
content: []byte("not real zip content"),
|
||||
filename: "archive.zip",
|
||||
mimeType: "application/zip",
|
||||
wantErr: ErrUnsupportedType,
|
||||
},
|
||||
{
|
||||
name: "invalid type executable rejected",
|
||||
content: []byte{0x7f, 0x45, 0x4c, 0x46},
|
||||
filename: "program.exe",
|
||||
mimeType: "application/x-executable",
|
||||
wantErr: ErrUnsupportedType,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Upload(ctx, UploadRequest{
|
||||
Content: bytes.NewReader(tt.content),
|
||||
Filename: tt.filename,
|
||||
MIMEType: tt.mimeType,
|
||||
UploadedBy: "agent-a",
|
||||
})
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Upload: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GarbageCollect(t *testing.T) {
|
||||
svc, db := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -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, attachments []string) ([]*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
|
||||
@@ -517,19 +522,21 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
channelMetaBytes, _ := json.Marshal(channelMetaObj)
|
||||
|
||||
channelMsg, err := s.msgService.SendMessage(ctx, fromAgent, "", body, messaging.SendOptions{
|
||||
Subject: fmt.Sprintf("channel:%s", ch.Name),
|
||||
Priority: priority,
|
||||
Metadata: string(channelMetaBytes),
|
||||
ChannelID: &channelID,
|
||||
Subject: fmt.Sprintf("channel:%s", ch.Name),
|
||||
Priority: priority,
|
||||
Metadata: string(channelMetaBytes),
|
||||
ChannelID: &channelID,
|
||||
ReplyTo: replyTo,
|
||||
Attachments: attachments,
|
||||
})
|
||||
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 +544,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 +554,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, 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, 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, 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, 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, nil)
|
||||
original := msgs[0]
|
||||
|
||||
replies, err := svc.BroadcastMessage(ctx, ch.ID, "agent-b", "reply to original", 5, "", &original.ID, nil)
|
||||
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, 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, 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, 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, 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, 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, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
|
||||
+58
-6
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
@@ -132,12 +133,34 @@ func (b *ServiceBridge) callSendMessage(ctx context.Context, args map[string]any
|
||||
replyTo = &v
|
||||
}
|
||||
|
||||
var attachmentHashes []string
|
||||
if attVal, ok := args["attachments"]; ok {
|
||||
switch v := attVal.(type) {
|
||||
case string:
|
||||
for _, h := range strings.Split(v, ",") {
|
||||
h = strings.TrimSpace(h)
|
||||
if h != "" {
|
||||
attachmentHashes = append(attachmentHashes, h)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
attachmentHashes = append(attachmentHashes, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
attachmentHashes = v
|
||||
}
|
||||
}
|
||||
|
||||
opts := messaging.SendOptions{
|
||||
Subject: getString(args, "subject", ""),
|
||||
Priority: getInt(args, "priority", 5),
|
||||
Metadata: getString(args, "metadata", ""),
|
||||
ChannelID: channelID,
|
||||
ReplyTo: replyTo,
|
||||
Subject: getString(args, "subject", ""),
|
||||
Priority: getInt(args, "priority", 5),
|
||||
Metadata: getString(args, "metadata", ""),
|
||||
ChannelID: channelID,
|
||||
ReplyTo: replyTo,
|
||||
Attachments: attachmentHashes,
|
||||
}
|
||||
|
||||
msg, err := b.msgService.SendMessage(ctx, b.agentName, to, body, opts)
|
||||
@@ -546,7 +569,36 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
var attachmentHashes []string
|
||||
if attVal, ok := args["attachments"]; ok {
|
||||
switch v := attVal.(type) {
|
||||
case string:
|
||||
for _, h := range strings.Split(v, ",") {
|
||||
h = strings.TrimSpace(h)
|
||||
if h != "" {
|
||||
attachmentHashes = append(attachmentHashes, h)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
attachmentHashes = append(attachmentHashes, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
attachmentHashes = v
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := b.channelService.BroadcastMessage(ctx, channelID, b.agentName, body, priority, metadata, replyTo, attachmentHashes)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -84,14 +84,15 @@ func (h *HybridToolRegistrar) myStatusTool() mcplib.Tool {
|
||||
|
||||
func (h *HybridToolRegistrar) sendMessageTool() mcplib.Tool {
|
||||
return mcplib.NewTool("send_message",
|
||||
mcplib.WithDescription("Send a message to another agent (DM) or to a channel. Specify exactly one of 'to' (agent name for DM) or 'channel' (channel name or numeric ID)."),
|
||||
mcplib.WithDescription("Send a message to another agent (DM) or to a channel. Supports attachments — upload files first via the execute tool, then pass the returned hashes here. Specify exactly one of 'to' (agent name for DM) or 'channel' (channel name or numeric ID)."),
|
||||
mcplib.WithString("to", mcplib.Description("Recipient agent name for direct messages")),
|
||||
mcplib.WithString("channel", mcplib.Description("Channel name or numeric ID for channel messages")),
|
||||
mcplib.WithString("body", mcplib.Description("Message body text"), mcplib.Required()),
|
||||
mcplib.WithString("subject", mcplib.Description("Conversation subject (optional)")),
|
||||
mcplib.WithNumber("priority", mcplib.Description("Message priority (1-10, default 5)"), mcplib.Min(1), mcplib.Max(10)),
|
||||
mcplib.WithString("metadata", mcplib.Description("JSON metadata object (optional)")),
|
||||
mcplib.WithNumber("reply_to", mcplib.Description("ID of the message to reply to (optional, for threading)")),
|
||||
mcplib.WithNumber("reply_to", mcplib.Description("ID of the parent message to reply to. Creates a threaded reply. Always use reply_to when responding to a message that is itself a thread reply, to keep conversations organized.")),
|
||||
mcplib.WithString("attachments", mcplib.Description("Comma-separated list of attachment hashes to link to this message. Upload attachments first using the upload_attachment action via the execute tool.")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,6 +324,16 @@ func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.
|
||||
replyTo = &v
|
||||
}
|
||||
|
||||
var attachmentHashes []string
|
||||
if attStr := req.GetString("attachments", ""); attStr != "" {
|
||||
for _, h := range strings.Split(attStr, ",") {
|
||||
h = strings.TrimSpace(h)
|
||||
if h != "" {
|
||||
attachmentHashes = append(attachmentHashes, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel message path.
|
||||
if channel != "" {
|
||||
if h.channelService == nil {
|
||||
@@ -335,7 +346,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, attachmentHashes)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("send_message to channel failed: %s", err)), nil
|
||||
}
|
||||
@@ -345,19 +356,30 @@ func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.
|
||||
messageID = messages[0].ID
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
result := map[string]any{
|
||||
"channel_id": channelID,
|
||||
"message_id": messageID,
|
||||
"status": "sent",
|
||||
})
|
||||
}
|
||||
|
||||
// Enrich channel messages with attachment info.
|
||||
if len(messages) > 0 && len(attachmentHashes) > 0 {
|
||||
h.msgService.EnrichMessages(ctx, messages)
|
||||
if len(messages[0].Attachments) > 0 {
|
||||
result["attachments"] = messages[0].Attachments
|
||||
}
|
||||
}
|
||||
|
||||
return resultJSON(result)
|
||||
}
|
||||
|
||||
// DM path.
|
||||
opts := messaging.SendOptions{
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
Metadata: metadataStr,
|
||||
ReplyTo: replyTo,
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
Metadata: metadataStr,
|
||||
ReplyTo: replyTo,
|
||||
Attachments: attachmentHashes,
|
||||
}
|
||||
|
||||
msg, err := h.msgService.SendMessage(ctx, agentName, to, body, opts)
|
||||
@@ -365,11 +387,21 @@ func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("send_message failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
result := map[string]any{
|
||||
"message_id": msg.ID,
|
||||
"conversation_id": msg.ConversationID,
|
||||
"status": msg.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// Enrich message with attachment info.
|
||||
if len(attachmentHashes) > 0 {
|
||||
h.msgService.EnrichMessages(ctx, []*messaging.Message{msg})
|
||||
if len(msg.Attachments) > 0 {
|
||||
result["attachments"] = msg.Attachments
|
||||
}
|
||||
}
|
||||
|
||||
return resultJSON(result)
|
||||
}
|
||||
|
||||
func (h *HybridToolRegistrar) handleSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
|
||||
@@ -2,12 +2,13 @@ package messaging
|
||||
|
||||
// SendOptions configures message sending behavior.
|
||||
type SendOptions struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Metadata string `json:"metadata,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
ReplyTo *int64 `json:"reply_to,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Metadata string `json:"metadata,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
ReplyTo *int64 `json:"reply_to,omitempty"`
|
||||
Attachments []string `json:"attachments,omitempty"` // attachment hashes to link
|
||||
}
|
||||
|
||||
// ReadOptions configures inbox reading behavior.
|
||||
@@ -25,14 +26,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,33 @@ 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)
|
||||
}
|
||||
|
||||
// AttachmentLinker links attachment hashes to message IDs. This avoids
|
||||
// importing the attachments package directly. Set via SetAttachmentLinker.
|
||||
type AttachmentLinker interface {
|
||||
AttachToMessage(ctx context.Context, hash string, messageID int64) error
|
||||
GetByMessageID(ctx context.Context, messageID int64) ([]AttachmentInfo, error)
|
||||
}
|
||||
|
||||
// MessagingService provides business logic for messaging operations.
|
||||
type MessagingService struct {
|
||||
store MessageStore
|
||||
tracer *trace.Tracer
|
||||
dispatcher dispatcher.EventDispatcher
|
||||
embeddings EmbeddingNotifier
|
||||
attLinker AttachmentLinker
|
||||
listeners []MessageListener
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -34,6 +56,21 @@ 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
|
||||
}
|
||||
|
||||
// SetAttachmentLinker sets the attachment linker for message-attachment binding.
|
||||
func (s *MessagingService) SetAttachmentLinker(l AttachmentLinker) {
|
||||
s.attLinker = l
|
||||
}
|
||||
|
||||
// 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 +158,29 @@ func (s *MessagingService) SendMessage(ctx context.Context, from, to, body strin
|
||||
return nil, fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
|
||||
// Link attachments if provided.
|
||||
if s.attLinker != nil && len(opts.Attachments) > 0 {
|
||||
for _, hash := range opts.Attachments {
|
||||
if err := s.attLinker.AttachToMessage(ctx, hash, msg.ID); err != nil {
|
||||
s.logger.Error("failed to link attachment",
|
||||
"hash", hash,
|
||||
"message_id", msg.ID,
|
||||
"error", 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,
|
||||
@@ -489,6 +549,44 @@ func (s *MessagingService) GetConversationIDsForDM(ctx context.Context, agentNam
|
||||
return s.store.GetConversationIDsForDM(ctx, agentNames, peerAgent, lastMessageID)
|
||||
}
|
||||
|
||||
// EnrichMessages populates ReplyCount and Attachments on a slice of messages.
|
||||
func (s *MessagingService) EnrichMessages(ctx context.Context, msgs []*Message) {
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]int64, len(msgs))
|
||||
for i, m := range msgs {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
|
||||
// Batch-load reply counts.
|
||||
counts, err := s.store.GetReplyCounts(ctx, ids)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to load reply counts", "error", err)
|
||||
} else {
|
||||
for _, m := range msgs {
|
||||
if c, ok := counts[m.ID]; ok {
|
||||
m.ReplyCount = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-load attachments.
|
||||
if s.attLinker != nil {
|
||||
for _, m := range msgs {
|
||||
atts, err := s.attLinker.GetByMessageID(ctx, m.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to load attachments", "message_id", m.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if len(atts) > 0 {
|
||||
m.Attachments = atts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetConversation returns a conversation and its messages.
|
||||
func (s *MessagingService) GetConversation(ctx context.Context, id int64) (*Conversation, []*Message, error) {
|
||||
conv, err := s.store.GetConversation(ctx, id)
|
||||
|
||||
@@ -728,5 +728,96 @@ func TestMessagingService_ReadInbox_DateFiltering(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// mockAttachmentLinker is a test double for the AttachmentLinker interface.
|
||||
type mockAttachmentLinker struct {
|
||||
attachments map[int64][]AttachmentInfo
|
||||
}
|
||||
|
||||
func (m *mockAttachmentLinker) AttachToMessage(_ context.Context, _ string, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAttachmentLinker) GetByMessageID(_ context.Context, messageID int64) ([]AttachmentInfo, error) {
|
||||
return m.attachments[messageID], nil
|
||||
}
|
||||
|
||||
func TestMessagingService_EnrichMessages(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Send a parent message and replies to it.
|
||||
parent, err := svc.SendMessage(ctx, "sender", "receiver", "parent message", SendOptions{Subject: "Enrich Test"})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage (parent): %v", err)
|
||||
}
|
||||
|
||||
replyTo := parent.ID
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := svc.SendMessage(ctx, "receiver", "sender", "reply", SendOptions{
|
||||
Subject: "Enrich Test",
|
||||
ReplyTo: &replyTo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage (reply %d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send a message with no replies.
|
||||
noReplies, err := svc.SendMessage(ctx, "sender", "receiver", "standalone", SendOptions{Subject: "Enrich Standalone"})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage (standalone): %v", err)
|
||||
}
|
||||
|
||||
t.Run("reply counts populated", func(t *testing.T) {
|
||||
msgs := []*Message{parent, noReplies}
|
||||
svc.EnrichMessages(ctx, msgs)
|
||||
|
||||
if parent.ReplyCount != 3 {
|
||||
t.Errorf("parent ReplyCount = %d, want 3", parent.ReplyCount)
|
||||
}
|
||||
if noReplies.ReplyCount != 0 {
|
||||
t.Errorf("noReplies ReplyCount = %d, want 0", noReplies.ReplyCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attachments populated when linker set", func(t *testing.T) {
|
||||
linker := &mockAttachmentLinker{
|
||||
attachments: map[int64][]AttachmentInfo{
|
||||
parent.ID: {
|
||||
{Hash: "abc123", OriginalFilename: "photo.png", Size: 1024, MIMEType: "image/png", IsImage: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc.SetAttachmentLinker(linker)
|
||||
|
||||
// Reset enrichment state.
|
||||
parent.ReplyCount = 0
|
||||
parent.Attachments = nil
|
||||
noReplies.ReplyCount = 0
|
||||
noReplies.Attachments = nil
|
||||
|
||||
msgs := []*Message{parent, noReplies}
|
||||
svc.EnrichMessages(ctx, msgs)
|
||||
|
||||
if parent.ReplyCount != 3 {
|
||||
t.Errorf("parent ReplyCount = %d, want 3", parent.ReplyCount)
|
||||
}
|
||||
if len(parent.Attachments) != 1 {
|
||||
t.Fatalf("parent Attachments count = %d, want 1", len(parent.Attachments))
|
||||
}
|
||||
if parent.Attachments[0].Hash != "abc123" {
|
||||
t.Errorf("attachment hash = %s, want abc123", parent.Attachments[0].Hash)
|
||||
}
|
||||
if noReplies.Attachments != nil {
|
||||
t.Errorf("noReplies Attachments should be nil, got %v", noReplies.Attachments)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty slice is a no-op", func(t *testing.T) {
|
||||
svc.EnrichMessages(ctx, []*Message{})
|
||||
// Should not panic or error.
|
||||
})
|
||||
}
|
||||
|
||||
// suppress unused import warning for storage package
|
||||
var _ = storage.RunMigrations
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type MessageStore interface {
|
||||
GetLastReadForDM(ctx context.Context, agentNames []string, peerAgent string) (int64, error)
|
||||
GetConversationIDsForChannel(ctx context.Context, channelID int64, lastMessageID int64) ([]int64, error)
|
||||
GetConversationIDsForDM(ctx context.Context, agentNames []string, peerAgent string, lastMessageID int64) ([]int64, error)
|
||||
GetReplyCounts(ctx context.Context, messageIDs []int64) (map[int64]int, error)
|
||||
}
|
||||
|
||||
// SQLiteMessageStore implements MessageStore using SQLite.
|
||||
@@ -492,6 +493,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 >= ?")
|
||||
@@ -956,6 +1008,44 @@ func (s *SQLiteMessageStore) GetConversationIDsForDM(ctx context.Context, agentN
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// GetReplyCounts returns a map of message ID → reply count for the given IDs.
|
||||
func (s *SQLiteMessageStore) GetReplyCounts(ctx context.Context, messageIDs []int64) (map[int64]int, error) {
|
||||
if len(messageIDs) == 0 {
|
||||
return map[int64]int{}, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(messageIDs))
|
||||
args := make([]any, len(messageIDs))
|
||||
for i, id := range messageIDs {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT reply_to, COUNT(*) FROM messages
|
||||
WHERE reply_to IN (%s)
|
||||
GROUP BY reply_to`,
|
||||
strings.Join(placeholders, ","),
|
||||
)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get reply counts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
counts := make(map[int64]int)
|
||||
for rows.Next() {
|
||||
var replyTo int64
|
||||
var count int
|
||||
if err := rows.Scan(&replyTo, &count); err != nil {
|
||||
return nil, fmt.Errorf("scan reply count: %w", err)
|
||||
}
|
||||
counts[replyTo] = count
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// scanMessage scans a single message from sql.Row.
|
||||
func scanMessage(row *sql.Row) (*Message, error) {
|
||||
var msg Message
|
||||
|
||||
@@ -1027,6 +1027,132 @@ func TestSQLiteMessageStore_GetChannelMessages_Offset(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteMessageStore_GetReplyCounts(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteMessageStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
seedAgent(t, db, "sender")
|
||||
seedAgent(t, db, "replier")
|
||||
|
||||
conv := &Conversation{Subject: "reply counts", CreatedBy: "sender"}
|
||||
if err := store.InsertConversation(ctx, conv); err != nil {
|
||||
t.Fatalf("InsertConversation: %v", err)
|
||||
}
|
||||
|
||||
// Insert parent message
|
||||
parent := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "replier",
|
||||
Body: "parent message",
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, parent); err != nil {
|
||||
t.Fatalf("InsertMessage (parent): %v", err)
|
||||
}
|
||||
|
||||
// Insert 3 replies to parent
|
||||
for i := 0; i < 3; i++ {
|
||||
reply := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "replier",
|
||||
ToAgent: "sender",
|
||||
ReplyTo: &parent.ID,
|
||||
Body: fmt.Sprintf("reply %d", i),
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, reply); err != nil {
|
||||
t.Fatalf("InsertMessage (reply %d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("parent with 3 replies", func(t *testing.T) {
|
||||
counts, err := store.GetReplyCounts(ctx, []int64{parent.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplyCounts: %v", err)
|
||||
}
|
||||
if counts[parent.ID] != 3 {
|
||||
t.Errorf("reply count for parent = %d, want 3", counts[parent.ID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("message with no replies returns 0", func(t *testing.T) {
|
||||
// Insert a message with no replies
|
||||
noReply := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "replier",
|
||||
Body: "no replies here",
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, noReply); err != nil {
|
||||
t.Fatalf("InsertMessage (noReply): %v", err)
|
||||
}
|
||||
|
||||
counts, err := store.GetReplyCounts(ctx, []int64{noReply.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplyCounts: %v", err)
|
||||
}
|
||||
if counts[noReply.ID] != 0 {
|
||||
t.Errorf("reply count for noReply = %d, want 0", counts[noReply.ID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple parents", func(t *testing.T) {
|
||||
// Insert a second parent with 2 replies
|
||||
parent2 := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "replier",
|
||||
Body: "second parent",
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, parent2); err != nil {
|
||||
t.Fatalf("InsertMessage (parent2): %v", err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
reply := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "replier",
|
||||
ToAgent: "sender",
|
||||
ReplyTo: &parent2.ID,
|
||||
Body: fmt.Sprintf("reply to parent2 %d", i),
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, reply); err != nil {
|
||||
t.Fatalf("InsertMessage (parent2 reply %d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
counts, err := store.GetReplyCounts(ctx, []int64{parent.ID, parent2.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplyCounts: %v", err)
|
||||
}
|
||||
if counts[parent.ID] != 3 {
|
||||
t.Errorf("reply count for parent = %d, want 3", counts[parent.ID])
|
||||
}
|
||||
if counts[parent2.ID] != 2 {
|
||||
t.Errorf("reply count for parent2 = %d, want 2", counts[parent2.ID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty slice returns empty map", func(t *testing.T) {
|
||||
counts, err := store.GetReplyCounts(ctx, []int64{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplyCounts: %v", err)
|
||||
}
|
||||
if len(counts) != 0 {
|
||||
t.Errorf("expected empty map, got %v", counts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteMessageStore_CombinedFiltersAndPagination(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteMessageStore(db)
|
||||
|
||||
@@ -14,6 +14,16 @@ const (
|
||||
StatusFailed = "failed"
|
||||
)
|
||||
|
||||
// AttachmentInfo is a lightweight attachment summary included in message responses.
|
||||
// It avoids importing the attachments package into the messaging package.
|
||||
type AttachmentInfo struct {
|
||||
Hash string `json:"hash"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Size int64 `json:"size"`
|
||||
MIMEType string `json:"mime_type"`
|
||||
IsImage bool `json:"is_image"`
|
||||
}
|
||||
|
||||
// Message represents a single message in the system.
|
||||
type Message struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -30,6 +40,8 @@ type Message struct {
|
||||
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
Attachments []AttachmentInfo `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// Conversation groups related messages into a thread.
|
||||
|
||||
@@ -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.BmgMnErP.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/i7g3n1jh.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/D2RS6Hlp.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C-40UA0L.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/4Fzv7G3k.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C0W-VSVk.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/ChDFVqbV.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.BVlIPsSc.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_vhg0t8 = {
|
||||
__sveltekit_nqckuv = {
|
||||
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.BmgMnErP.js"),
|
||||
import("/_app/immutable/entry/app.BVlIPsSc.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,35 @@
|
||||
# Specification Quality Checklist: Admin CLI & Docker Fixes
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-15
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.plan`.
|
||||
- FR-001 mentions "alpine:3.19" which is an implementation detail, but this is the explicit user request and core to the fix, so it's acceptable.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Admin Socket Contract: Channel Commands
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
**Date**: 2026-03-15
|
||||
|
||||
## Protocol
|
||||
|
||||
Unix domain socket at `/data/synapbus.sock` (default). JSON-RPC style, newline-delimited.
|
||||
|
||||
## New Commands
|
||||
|
||||
### `channels.create`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"command": "channels.create",
|
||||
"args": {
|
||||
"name": "news-feed",
|
||||
"description": "News feed channel"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `name` (string, required): Channel name. Must pass `ValidateChannelName` rules.
|
||||
- `description` (string, optional): Channel description. Defaults to empty.
|
||||
|
||||
**Success Response**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 42,
|
||||
"name": "news-feed",
|
||||
"description": "News feed channel",
|
||||
"type": "standard",
|
||||
"is_private": false,
|
||||
"created_by": "system",
|
||||
"created_at": "2026-03-15T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response** (duplicate name):
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "channel already exists"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response** (invalid name):
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "invalid channel name: ..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `channels.join`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"command": "channels.join",
|
||||
"args": {
|
||||
"channel": "news-feed",
|
||||
"agent": "my-agent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `channel` (string, required): Channel name to join.
|
||||
- `agent` (string, required): Agent name to add as member.
|
||||
|
||||
**Success Response**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"channel": "news-feed",
|
||||
"agent": "my-agent",
|
||||
"status": "joined"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response** (already member, idempotent):
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"channel": "news-feed",
|
||||
"agent": "my-agent",
|
||||
"status": "already_member"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response** (channel not found):
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "channel not found: news-feed"
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### `synapbus channels create`
|
||||
|
||||
```
|
||||
Usage:
|
||||
synapbus channels create [flags]
|
||||
|
||||
Flags:
|
||||
--name string Channel name (required)
|
||||
--description string Channel description
|
||||
|
||||
Global Flags:
|
||||
--socket string Path to admin Unix socket (default "/data/synapbus.sock")
|
||||
```
|
||||
|
||||
### `synapbus channels join`
|
||||
|
||||
```
|
||||
Usage:
|
||||
synapbus channels join [flags]
|
||||
|
||||
Flags:
|
||||
--channel string Channel name (required)
|
||||
--agent string Agent name (required)
|
||||
|
||||
Global Flags:
|
||||
--socket string Path to admin Unix socket (default "/data/synapbus.sock")
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# Data Model: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
**Date**: 2026-03-15
|
||||
|
||||
## No Schema Changes
|
||||
|
||||
This feature does not introduce any new database tables, columns, or migrations. All operations use existing entities:
|
||||
|
||||
### Existing Entities Used
|
||||
|
||||
#### Channel (existing)
|
||||
- `id` (int64): Auto-increment primary key
|
||||
- `name` (string): Unique, normalized channel name
|
||||
- `description` (string): Optional description
|
||||
- `type` (string): "standard", "blackboard", "auction"
|
||||
- `is_private` (bool): Whether channel requires invite
|
||||
- `is_system` (bool): Whether channel is system-managed
|
||||
- `created_by` (string): Agent name of creator
|
||||
- `created_at` (timestamp): Creation time
|
||||
|
||||
#### Membership (existing)
|
||||
- `channel_id` (int64): FK to channels
|
||||
- `agent_name` (string): Name of member agent
|
||||
- `role` (string): "owner", "member"
|
||||
- `joined_at` (timestamp): When the agent joined
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
CLI Command Admin Socket Handler Channel Service
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
channels create --name X → channels.create {name, desc} → CreateChannel(req)
|
||||
channels join --ch X --ag Y → channels.join {channel, agent} → GetChannelByName(X)
|
||||
→ JoinChannel(id, Y)
|
||||
```
|
||||
|
||||
### Validation Rules (existing, no changes)
|
||||
- Channel names: lowercase, alphanumeric + hyphens, 1-64 chars, no leading/trailing hyphens
|
||||
- Agent names: must exist in the agent registry
|
||||
- Channel join: idempotent (re-joining a channel the agent is already in is a no-op)
|
||||
- Private channels: require a pending invite before join
|
||||
@@ -0,0 +1,74 @@
|
||||
# Implementation Plan: Admin CLI & Docker Fixes
|
||||
|
||||
**Branch**: `006-admin-cli-docker-fixes` | **Date**: 2026-03-15 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/006-admin-cli-docker-fixes/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Fix four operational issues blocking reliable admin CLI usage in containerized SynapBus: switch Docker base image from `scratch` to `alpine:3.19` so admin socket CLI works via `kubectl exec`, add `synapbus channels create` and `synapbus channels join` CLI commands backed by new admin socket handlers, and change the default socket path from relative `./data/synapbus.sock` to absolute `/data/synapbus.sock`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (per go.mod)
|
||||
**Primary Dependencies**: spf13/cobra (CLI), go-chi/chi (HTTP), mark3labs/mcp-go (MCP)
|
||||
**Storage**: modernc.org/sqlite (pure Go, zero CGO)
|
||||
**Testing**: `go test ./...` (table-driven tests)
|
||||
**Target Platform**: linux/amd64, darwin/arm64 (Docker + local dev)
|
||||
**Project Type**: CLI / web-service (single binary)
|
||||
**Performance Goals**: Admin socket commands complete in < 1 second
|
||||
**Constraints**: Zero CGO, single binary, pure Go
|
||||
**Scale/Scope**: Single instance, admin-only operations
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | No new external dependencies. Alpine base image only adds shell availability. |
|
||||
| II. MCP-Native | PASS | Changes are admin CLI only, no MCP interface changes. |
|
||||
| III. Pure Go, Zero CGO | PASS | No new Go dependencies. Dockerfile still builds with `CGO_ENABLED=0`. |
|
||||
| IV. Multi-Tenant with Ownership | PASS | Admin socket is localhost-only, trusted operator context. |
|
||||
| V. Embedded OAuth 2.1 | N/A | No auth changes. |
|
||||
| VI. Semantic-Ready Storage | N/A | No storage schema changes. |
|
||||
| VII. Swarm Intelligence | N/A | No swarm pattern changes. |
|
||||
| VIII. Observable by Default | PASS | Channel create/join are traced via existing channel service. |
|
||||
| IX. Progressive Complexity | PASS | New CLI commands add no complexity for basic usage. |
|
||||
| X. Web UI | N/A | No UI changes. |
|
||||
|
||||
**Gate Result**: PASS — no violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/006-admin-cli-docker-fixes/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output (admin socket protocol)
|
||||
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
# Files modified:
|
||||
Dockerfile # scratch → alpine:3.19
|
||||
cmd/synapbus/admin.go # Add channels create/join commands, fix default socket path
|
||||
internal/admin/socket.go # Add channels.create and channels.join handlers
|
||||
cmd/synapbus/admin_test.go # Tests for new CLI commands
|
||||
|
||||
# Files unchanged but referenced:
|
||||
internal/channels/service.go # CreateChannel, JoinChannel (already exist)
|
||||
internal/channels/store.go # GetChannelByName (already exists)
|
||||
internal/admin/server.go # Services struct (already has Channels field)
|
||||
```
|
||||
|
||||
**Structure Decision**: This feature modifies 3 existing files and adds no new files. All changes fit within the existing project structure.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations to justify.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Quickstart: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
```bash
|
||||
# Build Docker image (now uses alpine base)
|
||||
docker build -t synapbus:dev .
|
||||
|
||||
# Run locally
|
||||
./synapbus serve --port 8080 --data ./data
|
||||
```
|
||||
|
||||
## New CLI Commands
|
||||
|
||||
### Create a channel
|
||||
```bash
|
||||
# With description
|
||||
synapbus channels create --name news-feed --description "News feed channel"
|
||||
|
||||
# Without description
|
||||
synapbus channels create --name alerts
|
||||
```
|
||||
|
||||
### Join an agent to a channel
|
||||
```bash
|
||||
synapbus channels join --channel news-feed --agent research-mcpproxy
|
||||
```
|
||||
|
||||
### In Kubernetes
|
||||
```bash
|
||||
# Now works because alpine base image provides /bin/sh
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels create --name news-feed
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels join --channel news-feed --agent my-agent
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list
|
||||
```
|
||||
|
||||
## Socket Path
|
||||
|
||||
The default socket path is now `/data/synapbus.sock` (absolute). Override with:
|
||||
|
||||
```bash
|
||||
# Environment variable
|
||||
export SYNAPBUS_SOCKET=/custom/path/synapbus.sock
|
||||
|
||||
# CLI flag
|
||||
synapbus --socket /custom/path/synapbus.sock channels list
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Verify Docker image base
|
||||
docker run --rm synapbus:dev sh -c "cat /etc/os-release"
|
||||
# Should show Alpine Linux
|
||||
|
||||
# Verify socket path default
|
||||
synapbus --help | grep socket
|
||||
# Should show: --socket string Path to admin Unix socket (default "/data/synapbus.sock")
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# Research: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
**Date**: 2026-03-15
|
||||
|
||||
## R1: Alpine vs Scratch Docker Base Image
|
||||
|
||||
**Decision**: Use `alpine:3.19` as the runtime base image.
|
||||
|
||||
**Rationale**: The `scratch` image has no shell, no `/bin/sh`, no filesystem utilities. This means `kubectl exec` cannot spawn any process other than the entrypoint binary itself. Since admin CLI commands need to connect to the Unix socket created by the running server process, exec'd processes need a working environment. Alpine adds ~7MB but provides `/bin/sh`, basic filesystem operations, and a working process environment.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `distroless/static` (Google): No shell, same problem as scratch.
|
||||
- `busybox`: Works but no package manager. Alpine is the standard minimal base.
|
||||
- `debian-slim`: ~80MB, unnecessarily large.
|
||||
|
||||
## R2: Admin Socket Protocol for Channel Operations
|
||||
|
||||
**Decision**: Add `channels.create` and `channels.join` commands to the existing admin socket dispatch table, following the exact pattern of existing commands (e.g., `agent.create`, `webhook.register`).
|
||||
|
||||
**Rationale**: The admin socket already has a well-established request/response pattern: JSON-RPC style `{command, args}` → `{ok, data, error}`. The channel service already exposes `CreateChannel` and `JoinChannel` methods. The admin server already holds a reference to the channel service via `Services.Channels`. No new wiring needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- HTTP admin API endpoint: Would require API key protection (user explicitly rejected this approach).
|
||||
- Direct database manipulation via CLI: Bypasses service layer validation, unsafe.
|
||||
|
||||
## R3: Default Socket Path
|
||||
|
||||
**Decision**: Change default from `./data/synapbus.sock` to `/data/synapbus.sock` (absolute).
|
||||
|
||||
**Rationale**: In containers, the working directory is `/` and the data volume is mounted at `/data`. The relative path `./data/synapbus.sock` resolves to `/data/synapbus.sock` from `/`, but this is confusing and fragile. An absolute default matches the Dockerfile's `--data /data` argument and the Helm chart's `volumeMount` at `/data`.
|
||||
|
||||
The `SYNAPBUS_SOCKET` environment variable and `--socket` flag still allow overriding for development (e.g., `--socket ./data/synapbus.sock` for local dev).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep relative path: Works in containers but confusing for users.
|
||||
- Use `$SYNAPBUS_DATA_DIR/synapbus.sock` as default: Over-engineered; the socket path flag already exists.
|
||||
|
||||
## R4: `channels.create` Admin Handler Design
|
||||
|
||||
**Decision**: The `channels.create` handler accepts `{name, description}` args, calls `channelService.CreateChannel` with `created_by: "system"`, and returns the created channel as JSON.
|
||||
|
||||
**Rationale**: Admin socket commands are implicitly trusted (localhost-only, process-level access). Using `"system"` as the creator matches the pattern used for the default `#general` channel. The `description` field is optional (defaults to empty string).
|
||||
|
||||
## R5: `channels.join` Admin Handler Design
|
||||
|
||||
**Decision**: The `channels.join` handler accepts `{channel, agent}` args, looks up the channel by name via `GetChannelByName`, then calls `JoinChannel(channelID, agentName)`. Returns success message.
|
||||
|
||||
**Rationale**: The CLI uses channel names (not IDs) because operators work with names. The service's `JoinChannel` already handles idempotency (re-joining is a no-op) and private channel invite checks.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Feature Specification: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature Branch**: `006-admin-cli-docker-fixes`
|
||||
**Created**: 2026-03-15
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Fix admin socket accessibility in Docker, add channels create/join CLI commands, fix default socket path"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Admin CLI Works in Kubernetes Pods (Priority: P1)
|
||||
|
||||
An operator needs to run admin CLI commands inside a Kubernetes pod (e.g., `kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list`). With the current `scratch` base image, there is no shell and the admin socket is unreachable via exec'd processes. Switching to `alpine` allows `kubectl exec` with a shell and gives the admin CLI a working environment.
|
||||
|
||||
**Why this priority**: This is a blocker — without this fix, all admin CLI operations fail after pod restart in production.
|
||||
|
||||
**Independent Test**: Build Docker image with alpine base, deploy to a test pod, run `kubectl exec ... -- /synapbus channels list` and verify it returns results.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a SynapBus pod running with the alpine-based image, **When** an operator runs `kubectl exec deploy/synapbus -- /synapbus channels list`, **Then** the command executes and returns channel data over the admin socket.
|
||||
2. **Given** a SynapBus pod running with the alpine-based image, **When** an operator runs `kubectl exec deploy/synapbus -- sh`, **Then** they get an interactive shell.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Create Channels via CLI (Priority: P1)
|
||||
|
||||
An operator needs to create channels without using the Web UI or REST API with session cookies. The `synapbus channels create` command should create a channel via the admin socket.
|
||||
|
||||
**Why this priority**: Required for automated provisioning scripts and headless setups.
|
||||
|
||||
**Independent Test**: Start SynapBus server, run `synapbus channels create --name test-channel --description "A test channel"`, then verify with `synapbus channels list`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a running SynapBus server, **When** an operator runs `synapbus channels create --name news-feed --description "News feed channel"`, **Then** the channel is created and a success response with channel details is printed.
|
||||
2. **Given** a running SynapBus server, **When** an operator runs `synapbus channels create --name news-feed` without `--description`, **Then** the channel is created with an empty description.
|
||||
3. **Given** a channel named "news-feed" already exists, **When** an operator runs `synapbus channels create --name news-feed`, **Then** an appropriate error message is displayed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Join Agents to Channels via CLI (Priority: P1)
|
||||
|
||||
An operator needs to add agents to channels via the admin CLI so agents can post messages. The `synapbus channels join` command should add an agent to a channel's membership.
|
||||
|
||||
**Why this priority**: Agents cannot post to channels they haven't joined; this is required for initial agent setup and automation.
|
||||
|
||||
**Independent Test**: Create a channel and an agent, run `synapbus channels join --channel test-channel --agent my-agent`, then verify with `synapbus channels show --name test-channel`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a channel "test-channel" and agent "my-agent" exist, **When** an operator runs `synapbus channels join --channel test-channel --agent my-agent`, **Then** the agent is added as a member and a success response is printed.
|
||||
2. **Given** an agent is already a member of "test-channel", **When** an operator runs `synapbus channels join --channel test-channel --agent my-agent`, **Then** the operation succeeds idempotently (no error).
|
||||
3. **Given** channel "nonexistent" does not exist, **When** an operator runs `synapbus channels join --channel nonexistent --agent my-agent`, **Then** an error message indicates the channel was not found.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Absolute Default Socket Path (Priority: P2)
|
||||
|
||||
The default socket path for admin CLI commands is currently `./data/synapbus.sock` (relative). In containers where CWD varies, this is confusing. The default should be `/data/synapbus.sock` (absolute) to match the container layout.
|
||||
|
||||
**Why this priority**: Quality-of-life improvement; the current relative path works but is confusing.
|
||||
|
||||
**Independent Test**: Run `synapbus --help` and verify the default socket path shows `/data/synapbus.sock`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the `--socket` flag is not provided, **When** the CLI resolves the admin socket path, **Then** it defaults to `/data/synapbus.sock`.
|
||||
2. **Given** the `SYNAPBUS_SOCKET` environment variable is set, **When** the CLI resolves the admin socket path, **Then** it uses the environment variable value.
|
||||
3. **Given** the `--socket` flag is provided with a custom path, **When** the CLI resolves the admin socket path, **Then** it uses the custom path.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when channel name contains invalid characters? The existing `ValidateChannelName` rules apply, and the CLI reports the validation error.
|
||||
- What happens when the admin socket is not reachable? The CLI prints a connection error with "is synapbus serve running?" hint.
|
||||
- What happens when an agent name doesn't exist during channel join? The operation fails with a clear error message from the channel service.
|
||||
- What happens when the `--name` flag is missing on `channels create`? Cobra enforces the required flag and prints usage.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The Docker image MUST use `alpine:3.19` as the runtime base image instead of `scratch`.
|
||||
- **FR-002**: The system MUST provide a `synapbus channels create` CLI command with `--name` (required) and `--description` (optional) flags.
|
||||
- **FR-003**: The `channels create` command MUST send a `channels.create` request over the admin socket and display the result.
|
||||
- **FR-004**: The admin socket server MUST handle `channels.create` commands by creating a channel via the channel service.
|
||||
- **FR-005**: The system MUST provide a `synapbus channels join` CLI command with `--channel` (required) and `--agent` (required) flags.
|
||||
- **FR-006**: The `channels join` command MUST send a `channels.join` request over the admin socket and display the result.
|
||||
- **FR-007**: The admin socket server MUST handle `channels.join` commands by looking up the channel by name and adding the agent as a member.
|
||||
- **FR-008**: The default value of the `--socket` persistent flag MUST be `/data/synapbus.sock` (absolute path).
|
||||
- **FR-009**: The `SYNAPBUS_SOCKET` environment variable MUST override the default socket path when the flag is not explicitly set.
|
||||
- **FR-010**: The Docker image MUST remain minimal — only the binary, TLS certs, and timezone data should be included from the build stage.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Channel**: Named communication space with type, description, privacy flag, and member list.
|
||||
- **Agent**: Named entity (AI or human) that can be a member of channels.
|
||||
- **Admin Socket**: Unix domain socket at a known path, used by CLI commands to communicate with the running server.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Operators can execute all admin CLI commands inside a Kubernetes pod via `kubectl exec` without errors.
|
||||
- **SC-002**: `synapbus channels create --name <name>` successfully creates a channel and returns channel details within 1 second.
|
||||
- **SC-003**: `synapbus channels join --channel <name> --agent <name>` successfully adds an agent to a channel within 1 second.
|
||||
- **SC-004**: The default socket path displayed in help text is `/data/synapbus.sock`.
|
||||
- **SC-005**: The Docker image size remains under 50MB (alpine adds minimal overhead vs scratch).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Alpine 3.19 is acceptable as the runtime base image (adds ~7MB over scratch).
|
||||
- The `channels.create` admin command uses `"system"` as the `created_by` field since admin socket operations are implicitly trusted.
|
||||
- The `channels.join` admin command adds the agent with the `"member"` role (not owner).
|
||||
- Channel type defaults to `"standard"` if not specified.
|
||||
- No `--private` or `--type` flags are needed for the initial `channels create` command — they can be added later.
|
||||
- The Helm chart deployment.yaml does not need changes since it already passes `--data /data`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Tasks: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
**Created**: 2026-03-15
|
||||
**Plan**: [plan.md](plan.md)
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] **T01**: Change default socket path from `./data/synapbus.sock` to `/data/synapbus.sock` in `cmd/synapbus/admin.go` (line 922) and update the `SYNAPBUS_SOCKET` env var check comparison string.
|
||||
- Files: `cmd/synapbus/admin.go`
|
||||
|
||||
## Phase 2: Core — Admin Socket Handlers
|
||||
|
||||
- [x] **T02**: Add `channels.create` handler to `internal/admin/socket.go` dispatch table and implement `handleChannelsCreate` method.
|
||||
- Files: `internal/admin/socket.go`
|
||||
- Depends on: T01
|
||||
|
||||
- [x] **T03**: Add `channels.join` handler to `internal/admin/socket.go` dispatch table and implement `handleChannelsJoin` method.
|
||||
- Files: `internal/admin/socket.go`
|
||||
- Depends on: T01
|
||||
|
||||
## Phase 3: Core — CLI Commands
|
||||
|
||||
- [x] **T04**: Add `synapbus channels create` cobra command with `--name` (required) and `--description` (optional) flags in `cmd/synapbus/admin.go`.
|
||||
- Files: `cmd/synapbus/admin.go`
|
||||
- Depends on: T02
|
||||
|
||||
- [x] **T05**: Add `synapbus channels join` cobra command with `--channel` (required) and `--agent` (required) flags in `cmd/synapbus/admin.go`.
|
||||
- Files: `cmd/synapbus/admin.go`
|
||||
- Depends on: T03
|
||||
|
||||
## Phase 4: Docker
|
||||
|
||||
- [x] **T06**: Change Dockerfile runtime stage from `FROM scratch` to `FROM alpine:3.19` and add `RUN apk add --no-cache ca-certificates tzdata` (removing COPY of certs/tzdata from builder).
|
||||
- Files: `Dockerfile`
|
||||
|
||||
## Phase 5: Tests & Validation
|
||||
|
||||
- [x] **T07**: Add unit tests for `channels.create` and `channels.join` admin socket handlers.
|
||||
- Files: `cmd/synapbus/admin_test.go`
|
||||
- Depends on: T04, T05
|
||||
|
||||
- [x] **T08**: Run `make build` and `make test` to verify all changes compile and pass.
|
||||
- Depends on: T01-T07
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user