30 Commits
Author SHA1 Message Date
Algis Dumbris 6ed4ce931a Merge branch '009-attachments-threads' into main
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
2026-03-17 16:36:31 +02:00
Algis DumbrisandClaude Opus 4.6 667b7a4c2e feat: file attachments and thread visibility (009-attachments-threads)
Web UI: paperclip button for file upload (images, PDFs, text), inline
attachment cards with file icon/name/size, image thumbnails with
fullscreen overlay, attachment display in thread panel.

Threads: always-visible reply count badges on messages, clickable to
open thread panel. reply_count and attachments enriched in all API
responses via batch queries.

MCP: attachments parameter on send_message tool, updated tool
descriptions for threading and attachment workflow guidance.

Backend: file type validation (allowlist), AttachmentLinker interface
to avoid circular deps, GetReplyCounts batch query, EnrichMessages
method on MessagingService.

Admin CLI: synapbus attachments backup/restore with tar.gz archives,
dedup-safe restore.

24 new test cases across 4 packages. All 24 test packages pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:36:21 +02:00
Algis DumbrisandClaude Opus 4.6 3820414166 fix: push subscribe sends flat key_p256dh/key_auth matching backend API
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
The browser PushSubscription nests keys under .keys but the backend
expects flat key_p256dh and key_auth fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:28:35 +02:00
Algis DumbrisandClaude Opus 4.6 09fa765c2e build: rebuild embedded dist with v0.7.1 fixes
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:23:31 +02:00
Algis DumbrisandClaude Opus 4.6 71288f64d8 fix: push notification toggle, textarea resize, mobile viewport, card alignment
1. Fix push toggle error: VAPID key field name mismatch (public_key → vapid_public_key)
2. Fix textarea auto-resize: proper height reset, overflow handling, mobile Enter
   inserts newline instead of sending (send via button on mobile)
3. Fix mobile viewport overflow: add overflow-x hidden to html/body, overflow-x
   hidden on content container
4. Fix dashboard cards: always 4 columns with responsive text sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:23:19 +02:00
Algis DumbrisandClaude Opus 4.6 15e7877ea0 Add MCP Registry auto-publish on release
- Add server.json with registry metadata
- Add mcp-registry job to release workflow using GitHub OIDC auth
- Version in server.json is auto-updated from git tag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:36:42 +02:00
Algis DumbrisandClaude Opus 4.6 55652c2aca build: rebuild embedded dist with v0.7.0 web UI
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:37:11 +02:00
Algis DumbrisandClaude Opus 4.6 30d62de350 feat: v0.7.0 — analytics dashboard, PWA, UX fixes, MCP prompts
Analytics: time-series message graph with 5 time spans (1h/4h/24h/7d/30d),
top-5 agents and channels leaderboards, summary cards. 4 new REST endpoints.

PWA: web app manifest, service worker with cache-first static/network-only
API strategy, push notifications via Web Push API with VAPID keys, push
subscription management endpoints, SQLite migration for subscriptions.

UX fixes: auto-resize compose textarea (3-12 lines), inline editable agent
display name, editable human display name in settings, smart mention/channel
highlighting (existing→link, deleted→inactive badge, unknown→plain text),
font size -/+ preference (12-24px persisted in localStorage), version footer
with GitHub link.

MCP: 4 prompts — daily-digest, agent-health-check, channel-overview,
debug-agent. Registered with prompt capabilities enabled.

Code review fixes: scoped push unsubscribe to user, capped analytics limit
at 100, hardened HTML strip regex, bounded SW cache, backend push unsub on
disable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:36:58 +02:00
Algis DumbrisandClaude Opus 4.6 d9ad668c31 refactor: move SynapBus protocol to global CLAUDE.md, remove project-level MCP
- Protocol section moved to ~/.claude/CLAUDE.md (available in all projects)
- Removed mcpproxy_lan code_execution section (synapbus connected directly)
- SynapBus MCP added at user level (no API key, uses OAuth)
- Removed per-project synapbus MCP configs from searcher, posts, synapbus

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:58:58 +02:00
Algis DumbrisandClaude Opus 4.6 c3b11f9f3d docs: autonomous execution summary for v0.6.0
7 features, 6 parallel agents, 31+ new tests, zero regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:41:00 +02:00
Algis Dumbris 4e9884a8f5 build: rebuild embedded dist with all v0.6.0 features 2026-03-16 20:39:02 +02:00
Algis Dumbris 0a61781f37 Merge branch 'worktree-agent-a7d51ffb' into 007-platform-features-bundle 2026-03-16 20:38:02 +02:00
Algis Dumbris 8dd7475d1c Merge branch 'worktree-agent-ada6cb83' into 007-platform-features-bundle 2026-03-16 20:38:02 +02:00
Algis Dumbris 29d200c2f5 Merge branch 'worktree-agent-ab3c39cc' into 007-platform-features-bundle 2026-03-16 20:38:02 +02:00
Algis DumbrisandClaude Opus 4.6 87f24afd58 feat: enterprise identity provider support — GitHub, Google, Azure AD login
Add external IdP authentication via OAuth (GitHub) and OIDC (Google, Azure AD).
Users can sign in with enterprise credentials; accounts are auto-provisioned
and linked on first login. Configured entirely via environment variables.

- schema/011_external_auth.sql: user_identities table + email column on users
- internal/auth/idp/: provider interface, GitHub OAuth, generic OIDC, store,
  handlers (list providers, login redirect, callback with auto-provisioning)
- internal/auth/user_store.go: GetUserByEmail + SetEmail for IdP linking
- cmd/synapbus/main.go: wire IdP routes + agent provisioner adapter
- web/src/routes/login/+page.svelte: IdP buttons above password form
- Tests: domain restriction, store CRUD, provider listing, user provisioning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:37:27 +02:00
Algis DumbrisandClaude Opus 4.6 24a7a33a2c feat: A2A inbound gateway — external agents can send tasks to SynapBus agents
Add JSON-RPC 2.0 endpoint at POST /a2a with three methods:
- message.send: validates target agent, creates tracked task, delivers DM
- tasks.get: returns task state, auto-completes when target agent replies
- tasks.cancel: transitions non-terminal tasks to CANCELED

Includes SQLite migration (010_a2a_tasks), task store, gateway with
interface-based dependencies, and 9 tests covering happy paths and
error cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:31:16 +02:00
Algis DumbrisandClaude Opus 4.6 9a00d7c5ee feat: mobile-responsive Web UI — sidebar drawer, hamburger menu, touch targets
On viewports < 768px (md breakpoint):
- Sidebar slides in as a drawer with dark overlay backdrop
- Hamburger button in the header toggles the sidebar
- Nav link clicks auto-close the drawer
- Sidebar items get 44px min-height for touch-friendly tapping
- Search input uses fluid width instead of fixed 320px

Desktop (>= 768px) behavior is unchanged: sidebar always visible,
main content offset by 260px, no hamburger button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:28:49 +02:00
Algis DumbrisandClaude Opus 4.6 65bdbc5674 feat: add SynapBus Communication Protocol to CLAUDE.md (F8)
Includes: mandatory inbox check, claim-process-done loop, ACK/DONE
channel convention, message formats, StalemateWorker awareness.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:28:24 +02:00
Algis Dumbris 1940a2838d Merge branch 'worktree-agent-a37a6029' into 007-platform-features-bundle 2026-03-16 20:25:00 +02:00
Algis Dumbris 87dc10a336 Merge branch 'worktree-agent-ae256336' into 007-platform-features-bundle 2026-03-16 20:25:00 +02:00
Algis Dumbris a1890d645b Merge branch 'worktree-agent-acb87cea' into 007-platform-features-bundle 2026-03-16 20:25:00 +02:00
Algis DumbrisandClaude Opus 4.6 050b3cbde3 feat: A2A Agent Card discovery endpoint at /.well-known/agent-card.json
Add public A2A Agent Card endpoint that exposes registered agents as skills
for cross-platform agent discovery. Includes admin CLI for updating agent
capabilities, which populate skill tags and descriptions in the card.

- internal/a2a: new package with AgentCard generator, HTTP handler, and tests
- agents store/service: add ListAllActiveAgents (excludes human accounts)
- admin socket: add agent.update_capabilities command
- admin CLI: add `agent update-capabilities --name --capabilities` subcommand
- main.go: register /.well-known/agent-card.json route (public, no auth)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:24:22 +02:00
Algis DumbrisandClaude Opus 4.6 380609b6be feat: stalemate worker — enforce message acknowledgment with reminders and escalation
Background worker that detects stale messages and takes corrective action:
- Auto-fails DMs stuck in "processing" after configurable timeout (default 24h)
- Sends system DM reminders for pending messages after ReminderAfter (default 4h)
- Escalates unprocessed messages to #approvals channel after EscalateAfter (default 48h)
- Deduplicates reminders and escalations to avoid spam
- Configurable via SYNAPBUS_STALEMATE_* environment variables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:22:49 +02:00
Algis DumbrisandClaude Opus 4.6 d0b548f75f feat: add reply_to parameter to send_channel_message for threading support
The BroadcastMessage function now accepts a replyTo parameter, allowing
channel messages to reference a parent message ID and create threads.
Updated all callers (MCP bridge, hybrid tools, tests) and added a test
verifying reply_to is correctly stored and returned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:21:52 +02:00
Algis DumbrisandClaude Opus 4.6 75d6238f1d plan: 007 platform features — research, data model, contracts, quickstart
Phase 0-1 complete: technical decisions, entity schemas (a2a_tasks,
user_identities, identity_providers), API contracts (A2A JSON-RPC,
IdP routes, StalemateWorker config), and quickstart guide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:17:08 +02:00
Algis DumbrisandClaude Opus 4.6 a00394bd2b spec: 007 platform features bundle — StalemateWorker, A2A, mobile, IdP, K8s handlers
8 features specified: StalemateWorker message enforcement, channel reply_to,
A2A Agent Cards + inbound gateway, mobile-responsive UI, K8s reactive agents,
enterprise IdP (GitHub/Google/Azure AD), CLAUDE.md acknowledgment protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:12:48 +02:00
Algis DumbrisandClaude Opus 4.6 bd166843d6 docs: roadmap research — A2A, AG-UI, mobile, K8s agents, enterprise IdP
Synthesized findings from 7 parallel research agents covering:
- A2A integration (Agent Cards + inbound gateway)
- AG-UI assessment (medium-term, complement SSE)
- User-level MCP identity (claude-algis + gemini-algis via MCPProxy)
- Mobile access (responsive Web UI + PWA push)
- Always-online agents (CronJobs + K8s Job Handlers, no daemons)
- Enterprise IdP (GitHub/Google/Azure AD via go-oidc)
- Task acknowledgment (claim-done lifecycle + StalemateWorker)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:30:04 +02:00
Algis DumbrisandClaude Opus 4.6 13ef970bc4 docs: agent communication guide — Claude Code, Gemini CLI, SynapBus integration
Comprehensive guide covering MCP config, CLAUDE.md/GEMINI.md instructions,
skills (/bus, /inbox), hooks for auto-inbox-check, channel design,
message format conventions, cross-agent patterns, and anti-patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:25:58 +02:00
Algis DumbrisandClaude Opus 4.6 1dd332adf7 fix: OAuth token introspection "context canceled" on concurrent MCP connections
Decouple fosite token introspection from the HTTP request context using
context.WithoutCancel + 10s timeout. When claude.ai opens multiple
concurrent MCP connections and one disconnects, the token validation
for subsequent connections no longer fails with "context canceled".

Fixes Bug #6 from #bugs-synapbus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:59:23 +02:00
Algis DumbrisandClaude Opus 4.6 18d8179061 fix: derive OAuth URLs dynamically from request headers for tunnel/proxy support
When SYNAPBUS_BASE_URL is empty or set to "auto", the OAuth metadata
handler now reads X-Forwarded-Proto and X-Forwarded-Host headers to
construct correct OAuth URLs. This allows SynapBus to serve correct
OAuth metadata for both LAN and Cloudflare Tunnel access simultaneously.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:50:06 +02:00
122 changed files with 12578 additions and 215 deletions
+26
View File
@@ -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
+5
View File
@@ -100,6 +100,11 @@ make lint # Run linters
- SQLite (modernc.org/sqlite, pure Go) — single DB file in `--data` directory (004-embeddings-retention-inbox)
- Go 1.25+ (per go.mod) + spf13/cobra (CLI), go-chi/chi (HTTP), mark3labs/mcp-go (MCP) (006-admin-cli-docker-fixes)
- modernc.org/sqlite (pure Go, zero CGO) (006-admin-cli-docker-fixes)
- Go 1.25+ (per go.mod) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), ory/fosite (OAuth), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors). NEW: coreos/go-oidc/v3 (OIDC), golang.org/x/oauth2 (OAuth client) (007-platform-features-bundle)
- Go 1.25+ (backend), SvelteKit 2 + Svelte 5 (frontend), SvelteKit (website) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), SherClockHolmes/webpush-go (push notifications — NEW) (008-webui-pwa-analytics)
- SQLite (existing DB, 1 new migration for push_subscriptions), localStorage (font size) (008-webui-pwa-analytics)
- 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)
+68 -73
View File
@@ -1,96 +1,91 @@
# Autonomous Implementation Summary
# Autonomous Implementation Summary: Attachments & Threads Enhancement
**Feature**: Admin CLI & Docker Fixes
**Branch**: `006-admin-cli-docker-fixes`
**Date**: 2026-03-15
**Status**: COMPLETE — 8 of 8 tasks implemented, all tests pass, binary builds
**Branch**: `009-attachments-threads`
**Date**: 2026-03-17
**Status**: Complete
## What Was Built
### 1. Alpine Docker Base Image (T06)
### Attachments (Web UI + MCP + Admin CLI)
**Problem**: `scratch` base image has no shell — `kubectl exec` into the pod can't run admin CLI commands.
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.
**Solution**: Changed `FROM scratch` to `FROM alpine:3.19` in the runtime stage. Alpine provides `/bin/sh` and a working process environment. TLS certs and timezone data are now installed via `apk` instead of copied from the builder stage.
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.
**File Modified**: `Dockerfile`
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.
### 2. `synapbus channels create` CLI Command (T02, T04)
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.
**Problem**: No CLI command to create channels — had to use REST API with session cookies.
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).
**Solution**: Added `channels.create` admin socket handler and `synapbus channels create` cobra command:
- `--name` (required): Channel name
- `--description` (optional): Channel description
- Creates channel via the channel service with `created_by: "system"`, type `"standard"`
- Returns channel details as JSON
### Threads
**Files Modified**: `cmd/synapbus/admin.go`, `internal/admin/socket.go`
1. **Thread visibility**: Messages with replies show an always-visible "N replies" badge with a chat bubble icon. Clicking opens the thread panel.
### 3. `synapbus channels join` CLI Command (T03, T05)
2. **Reply count in API**: All message API responses now include `reply_count` (batch-loaded via efficient GROUP BY query).
**Problem**: No CLI command to add agents to channels.
3. **MCP threading**: `reply_to` parameter description updated to clearly guide agents on threading behavior. Thread context visible in MCP responses.
**Solution**: Added `channels.join` admin socket handler and `synapbus channels join` cobra command:
- `--channel` (required): Channel name to join
- `--agent` (required): Agent name to add
- Looks up channel by name, calls `JoinChannel` (idempotent)
- Reports `"joined"` or `"already_member"` status
4. **Attachment display in threads**: Thread panel also renders attachment thumbnails/file icons.
**Files Modified**: `cmd/synapbus/admin.go`, `internal/admin/socket.go`
### 4. Absolute Default Socket Path (T01)
**Problem**: Default `./data/synapbus.sock` is confusing in containers where CWD varies.
**Solution**: Changed default socket path from `./data/synapbus.sock` to `/data/synapbus.sock` in both the `--socket` flag definition and the `SYNAPBUS_SOCKET` env var comparison.
**File Modified**: `cmd/synapbus/admin.go`
## Tests Added (T07)
| Test | Description |
|------|-------------|
| `TestChannelsCreateCommandRegistered` | Verifies `channels create` subcommand exists |
| `TestChannelsCreateRequiredFlags` | Verifies `--name` is required, `--description` is optional |
| `TestChannelsJoinCommandRegistered` | Verifies `channels join` subcommand exists |
| `TestChannelsJoinRequiredFlags` | Verifies `--channel` and `--agent` are both required |
| `TestDefaultSocketPath` | Verifies default is `/data/synapbus.sock` |
## Verification Results (T08)
| Check | Result |
|-------|--------|
| `go build ./...` | PASS |
| `go test ./...` | ALL PASS (24 packages, 0 failures) |
| Zero CGO | Confirmed (CGO_ENABLED=0 in Dockerfile) |
| No regressions | All 14 existing CLI tests still pass |
## Files Changed
## Files Modified
### Backend (Go)
| File | Changes |
|------|---------|
| `Dockerfile` | `FROM scratch` → `FROM alpine:3.19` + `apk add --no-cache ca-certificates tzdata` |
| `cmd/synapbus/admin.go` | Default socket `/data/synapbus.sock`, `channels create` + `channels join` commands |
| `cmd/synapbus/admin_test.go` | 5 new tests for commands, flags, and default socket |
| `internal/admin/socket.go` | `channels.create` + `channels.join` handlers, `channels` import |
| `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 |
## CLI Commands Added
### 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 |
| Command | Description |
|---------|-------------|
| `synapbus channels create --name X [--description Y]` | Create a new channel |
| `synapbus channels join --channel X --agent Y` | Add an agent to a channel |
### 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 |
## Usage Examples
## Test Results
```bash
# In Kubernetes (now works with alpine base)
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels create --name news-feed --description "News feed"
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels join --channel news-feed --agent research-mcpproxy
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list
- **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
# Local development
synapbus --socket ./data/synapbus.sock channels create --name test-channel
synapbus --socket ./data/synapbus.sock channels join --channel test-channel --agent my-agent
```
## 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
+214 -2
View File
@@ -1,11 +1,15 @@
package main
import (
"archive/tar"
"bufio"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"text/tabwriter"
@@ -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{
@@ -963,7 +991,48 @@ 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", "/tmp/synapbus.sock", "Path to admin Unix socket")
@@ -988,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])
}
+153
View File
@@ -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"
@@ -278,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
@@ -305,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)
@@ -477,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)
@@ -514,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)
@@ -530,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)
@@ -538,6 +586,14 @@ func runServe(cmd *cobra.Command, args []string) error {
r.Mount("/mcp", mcpSrv.Handler())
})
// 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)
@@ -546,6 +602,13 @@ func runServe(cmd *cobra.Command, args []string) error {
// 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)
apiRouter := api.NewRouterWithConfig(api.RouterConfig{
@@ -560,6 +623,9 @@ func runServe(cmd *cobra.Command, args []string) error {
SSEHub: sseHub,
Broadcaster: sseBroadcaster,
SessionMiddleware: sessionMiddleware,
DB: db.DB,
Version: version,
PushService: pushService,
})
r.Mount("/", apiRouter)
@@ -648,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()
@@ -746,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
@@ -770,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) {
@@ -810,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
}
+320
View File
@@ -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 |
+234
View File
@@ -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
+7 -3
View File
@@ -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
+37 -2
View File
@@ -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=
+133
View File
@@ -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"},
}
}
+218
View File
@@ -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)
}
}
+306
View File
@@ -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, &params); 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, &params); 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, &params); 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)
}
+483
View File
@@ -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)
}
}
+56
View File
@@ -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)
}
}
+132
View File
@@ -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()
}
+2 -1
View File
@@ -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)"},
+31
View File
@@ -139,6 +139,8 @@ func (s *AdminServer) dispatch(req Request) Response {
return s.handleAgentDelete(ctx, req.Args)
case "agent.revoke_key":
return s.handleAgentRevokeKey(ctx, req.Args)
case "agent.update_capabilities":
return s.handleAgentUpdateCapabilities(ctx, req.Args)
// --- audit commands ---
case "audit.list":
@@ -446,6 +448,35 @@ func (s *AdminServer) handleAgentRevokeKey(ctx context.Context, args json.RawMes
}}
}
func (s *AdminServer) handleAgentUpdateCapabilities(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
Capabilities json.RawMessage `json:"capabilities"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Name == "" {
return Response{OK: false, Error: "name is required"}
}
if len(p.Capabilities) == 0 {
return Response{OK: false, Error: "capabilities is required"}
}
if !json.Valid(p.Capabilities) {
return Response{OK: false, Error: "capabilities must be valid JSON"}
}
agent, err := s.services.Agents.UpdateAgent(ctx, p.Name, "", p.Capabilities)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"name": agent.Name,
"capabilities": json.RawMessage(agent.Capabilities),
}}
}
// ---------- audit handlers ----------
func (s *AdminServer) handleAuditList(ctx context.Context, args json.RawMessage) Response {
+7 -3
View File
@@ -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
}
+6
View File
@@ -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) {
+13
View File
@@ -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
+282
View File
@@ -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,
})
}
+556
View File
@@ -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)
}
})
}
}
+2
View File
@@ -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)
+3
View File
@@ -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)
+24 -8
View File
@@ -86,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]
@@ -122,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)
}
@@ -230,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,
@@ -245,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 {
@@ -307,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)
@@ -319,6 +327,8 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
// 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)
}
@@ -465,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,
@@ -505,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),
@@ -550,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)
+1 -1
View File
@@ -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)
}
+112
View File
@@ -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(),
})
}
+37
View File
@@ -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,9 +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.
@@ -196,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)
+24
View File
@@ -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",
})
}
+87
View File
@@ -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")
}
}
+24
View File
@@ -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
}
+29
View File
@@ -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
+5 -4
View File
@@ -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.
+5
View 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 == "" {
+74
View File
@@ -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()
+64 -3
View File
@@ -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{
+93
View File
@@ -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
}
+140
View File
@@ -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")
}
+349
View File
@@ -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
}
+392
View File
@@ -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
}
+174
View File
@@ -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)
}
+37
View File
@@ -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"`
}
+97
View File
@@ -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()
}
+6 -2
View File
@@ -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"`)
+55
View File
@@ -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
}
+7 -5
View File
@@ -465,7 +465,7 @@ 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
@@ -522,10 +522,12 @@ 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)
+23 -10
View File
@@ -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)
}
@@ -568,7 +568,7 @@ func TestService_BroadcastMessage(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", "no-dm-test", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no-dm-test", 5, "", nil, nil)
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
@@ -583,7 +583,7 @@ func TestService_BroadcastMessage(t *testing.T) {
})
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" {
@@ -594,7 +594,7 @@ func TestService_BroadcastMessage(t *testing.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", "auto-joined", 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)
}
@@ -604,6 +604,19 @@ func TestService_BroadcastMessage(t *testing.T) {
}
})
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",
@@ -612,7 +625,7 @@ func TestService_BroadcastMessage(t *testing.T) {
t.Fatalf("create private channel: %v", err)
}
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider2")
_, err = svc.BroadcastMessage(ctx, privCh.ID, "outsider2", "unauthorized", 5, "")
_, err = svc.BroadcastMessage(ctx, privCh.ID, "outsider2", "unauthorized", 5, "", nil, nil)
if !errors.Is(err, ErrNotChannelMember) {
t.Errorf("expected ErrNotChannelMember for private channel, got %v", err)
}
@@ -631,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)
}
@@ -664,7 +677,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
})
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)
}
@@ -694,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)
}
@@ -719,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)
}
@@ -739,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
View File
@@ -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
}
+503
View File
@@ -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
}
+339
View File
@@ -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")
}
})
}
+7 -1
View File
@@ -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
}
+43 -11
View File
@@ -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) {
+7 -6
View File
@@ -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.
+64
View File
@@ -24,12 +24,20 @@ 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
}
@@ -53,6 +61,11 @@ 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)
@@ -145,6 +158,19 @@ 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)
@@ -523,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)
+91
View File
@@ -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
+471
View File
@@ -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)
}
+480
View File
@@ -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
View File
@@ -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.
@@ -1007,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
+126
View File
@@ -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)
+12
View File
@@ -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.
+279
View File
@@ -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] + "..."
}
+340
View File
@@ -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)
}
}
+104
View File
@@ -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
}
+228
View File
@@ -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)
}
}
+14
View File
@@ -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);
+15 -11
View File
@@ -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.c_zi0RIt.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CM33bEJp.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DcCVF7Hp.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Dxl4bgMI.js" rel="modulepreload">
<link href="/_app/immutable/chunks/ZMlvm3v3.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DGCkYcAF.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BoudhnWu.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.BNUVkI2e.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_ioo7b2 = {
__sveltekit_nqckuv = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.c_zi0RIt.js"),
import("/_app/immutable/entry/app.BNUVkI2e.js")
import("/_app/immutable/entry/start.BmgMnErP.js"),
import("/_app/immutable/entry/app.BVlIPsSc.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+14
View File
@@ -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);
+23
View File
@@ -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);
+12
View File
@@ -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
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.synapbus/synapbus",
"description": "MCP-native agent-to-agent messaging hub with channels, DMs, and semantic search",
"repository": {
"url": "https://github.com/synapbus/synapbus",
"source": "github"
},
"version": "0.7.0",
"packages": []
}
@@ -0,0 +1,39 @@
# Specification Quality Checklist: SynapBus v0.6.0 Platform Features Bundle
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-16
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All 8 features specified with P1/P2/P3 priority levels
- 20 functional requirements defined
- 9 success criteria established
- 6 edge cases documented
- 12 assumptions recorded
- Spec is ready for /speckit.plan
@@ -0,0 +1,67 @@
# A2A Endpoint Contract
## Agent Card Discovery
```
GET /.well-known/agent-card.json
Response: 200 OK
Content-Type: application/json
```
Response body: A2A Agent Card v1.0 with:
- `name`: "SynapBus Hub"
- `description`: instance description
- `skills[]`: one per active agent (id=agent.name, name=agent.display_name, description from capabilities)
- `security_schemes`: apiKey (Bearer header) + oauth2
- `supported_interfaces[0].url`: `{base_url}/a2a`
- `supported_interfaces[0].protocol_binding`: "JSONRPC"
## JSON-RPC Endpoint
```
POST /a2a
Content-Type: application/json
Authorization: Bearer <api-key-or-oauth-token>
```
### message.send
Request:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "message.send",
"params": {
"message": {
"role": "user",
"parts": [{"text": "Research MCP security patterns"}],
"metadata": {"target_agent": "research-mcpproxy"}
}
}
}
```
Response:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"task": {
"id": "uuid-here",
"state": "SUBMITTED",
"context_id": "ctx-uuid",
"history": [...]
}
}
}
```
### tasks.get
Request: `{"jsonrpc":"2.0","id":2,"method":"tasks.get","params":{"id":"task-uuid"}}`
### tasks.cancel
Request: `{"jsonrpc":"2.0","id":3,"method":"tasks.cancel","params":{"id":"task-uuid"}}`
@@ -0,0 +1,40 @@
# Identity Provider Routes Contract
## List Providers
```
GET /auth/providers
Response: 200 OK
```
```json
{
"providers": [
{"id": "github", "type": "github", "display_name": "GitHub"},
{"id": "google", "type": "oidc", "display_name": "Google"},
{"id": "azuread-gcore", "type": "oidc", "display_name": "Microsoft (Gcore)"}
]
}
```
Returns empty array if no IdPs configured. Only enabled providers are returned.
## Initiate Login
```
GET /auth/login/{provider}
Response: 302 Redirect to external IdP authorization URL
```
Sets a `state` cookie for CSRF protection. Redirects to the IdP's authorization endpoint with configured client_id, redirect_uri, and scopes.
## Callback
```
GET /auth/callback/{provider}?code=...&state=...
Response: 302 Redirect to /
```
Validates state parameter, exchanges code for tokens, fetches/verifies user identity, creates or links local user, sets session cookie, redirects to Web UI home.
Error case: Redirects to `/login?error=<reason>`.
@@ -0,0 +1,25 @@
# StalemateWorker Configuration Contract
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| SYNAPBUS_STALEMATE_PROCESSING_TIMEOUT | 24h | Auto-fail processing DMs older than this |
| SYNAPBUS_STALEMATE_REMINDER_AFTER | 4h | Send reminder for pending DMs older than this |
| SYNAPBUS_STALEMATE_ESCALATE_AFTER | 48h | Escalate pending DMs to #approvals after this |
| SYNAPBUS_STALEMATE_INTERVAL | 15m | How often the worker checks (minimum 1m) |
## Duration Format
Supports Go `time.ParseDuration` format: `24h`, `30m`, `1h30m`, etc.
Also supports day suffix: `7d` = 168h.
## Behavior
1. Worker runs every INTERVAL (default 15m)
2. Queries DMs only (`to_agent IS NOT NULL`)
3. Skips messages where `from_agent = 'system'` or `to_agent = 'system'`
4. For processing messages: checks `claimed_at + PROCESSING_TIMEOUT < now()`
5. For pending messages: checks `created_at + REMINDER_AFTER < now()` (reminder) and `created_at + ESCALATE_AFTER < now()` (escalation)
6. Avoids duplicate reminders by checking if a system reminder for this message already exists
7. Escalation message includes: original sender, original body (truncated), age, target agent
@@ -0,0 +1,74 @@
# Data Model: SynapBus v0.6.0
## Existing Entities (Modified)
### Messages (existing table)
- No schema changes. StalemateWorker queries existing `status`, `claimed_at`, `created_at`, `to_agent`, `from_agent` columns.
- `reply_to` column already exists (added in migration 007_threads.sql).
### Agents (existing table)
- `capabilities` column already exists (JSON, currently unused).
- A2A Agent Cards will read from this column.
- Admin CLI `agent update-capabilities` will write to this column.
## New Entities
### A2A Tasks (migration 010)
| Field | Type | Description |
|-------|------|-------------|
| id | TEXT (UUID) PRIMARY KEY | A2A task identifier |
| context_id | TEXT | Groups related tasks |
| target_agent | TEXT NOT NULL | SynapBus agent name |
| source_agent | TEXT | External agent identifier |
| conversation_id | INTEGER | Maps to SynapBus conversation |
| state | TEXT NOT NULL | SUBMITTED, WORKING, COMPLETED, FAILED, CANCELED |
| created_at | TIMESTAMP | Auto-set |
| updated_at | TIMESTAMP | Auto-set on state change |
### User Identities (migration 011)
| Field | Type | Description |
|-------|------|-------------|
| id | INTEGER PRIMARY KEY | Auto-increment |
| user_id | INTEGER FK → users(id) | Local user link |
| provider | TEXT NOT NULL | 'github', 'google', 'azuread' |
| external_id | TEXT NOT NULL | Provider's stable user ID |
| email | TEXT | Email from provider |
| display_name | TEXT | Name from provider |
| raw_claims | TEXT DEFAULT '{}' | Full JSON claims |
| created_at | TIMESTAMP | Auto-set |
| UNIQUE(provider, external_id) | | |
### Identity Providers (migration 011)
| Field | Type | Description |
|-------|------|-------------|
| id | TEXT PRIMARY KEY | 'github', 'google', 'azuread-gcore' |
| type | TEXT NOT NULL | 'github', 'oidc' |
| display_name | TEXT NOT NULL | Button label |
| client_id | TEXT NOT NULL | OAuth client ID |
| client_secret_encrypted | TEXT NOT NULL | Encrypted secret |
| issuer_url | TEXT | OIDC discovery URL (NULL for GitHub) |
| scopes | TEXT DEFAULT '[]' | JSON array |
| allowed_domains | TEXT DEFAULT '[]' | JSON array |
| group_mapping | TEXT DEFAULT '{}' | JSON: external group → role |
| tenant_id | TEXT | Azure AD tenant |
| enabled | INTEGER DEFAULT 1 | Active flag |
| created_at | TIMESTAMP | Auto-set |
## State Transitions
### A2A Task States
```
SUBMITTED → WORKING → COMPLETED (terminal)
→ FAILED (terminal)
→ CANCELED (terminal)
```
### Message Stalemate Flow
```
pending ──(4h)──→ system reminder DM
pending ──(48h)──→ escalation to #approvals
processing ──(24h)──→ auto-fail with "claim timeout exceeded"
```
+109
View File
@@ -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).
+217
View File
@@ -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.
+102
View File
@@ -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
```
+216
View File
@@ -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
+100
View File
@@ -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.
+264
View File
@@ -0,0 +1,264 @@
# Feature Specification: SynapBus v0.7.0 — WebUI Analytics, PWA, UX Fixes, Website, MCP Prompts
**Feature Branch**: `008-webui-pwa-analytics`
**Created**: 2026-03-17
**Status**: Draft
**Input**: Analytics dashboard with time-series graphs, PWA conversion with push notifications, 6 UX fixes, website messaging update, and MCP prompt additions.
## Assumptions
1. **Chart rendering**: Use a lightweight SVG-based chart approach in Svelte to keep the bundle small and avoid heavy external dependencies.
2. **Push notifications**: Use the Web Push API with VAPID keys. The server generates VAPID keys on first run and stores them in the data directory. Push subscriptions are stored per-user in SQLite.
3. **PWA manifest**: Standard web app manifest with SynapBus branding. Service worker caches static assets and handles offline fallback.
4. **Font size range**: 12px to 24px in 2px increments, default 16px. Stored in localStorage per user.
5. **Version endpoint**: The Go binary embeds the git tag at build time via `-ldflags`. A `/api/version` endpoint exposes it.
6. **Analytics data source**: All analytics are derived from existing message data in SQLite. No new data collection. Aggregation queries run on-demand with reasonable caching (60s TTL).
7. **Agent name editability**: Only the `display_name` field is editable (the `name` field is immutable as it's used for routing).
8. **Website repo**: Located at `~/repos/synapbus-website/`, Astro + Tailwind stack, deployed to Cloudflare Pages.
9. **MCP prompts**: Added as MCP protocol prompt resources, discoverable via `prompts/list` and invocable via `prompts/get`.
10. **Mention/channel validation**: Uses existing API data (agent list, channel list) to validate references at render time, not at send time.
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Analytics Dashboard (Priority: P1)
A human owner opens the SynapBus Web UI dashboard and sees an at-a-glance overview of messaging activity: a time-series graph of messages over time, the top 5 most active agents, the top 5 busiest channels, and summary cards showing total channels and agents. They can switch the time span to zoom in on the last hour or zoom out to the last month.
**Why this priority**: The dashboard is currently minimal (recent messages list). Analytics give the human owner visibility into agent activity patterns, helping them identify bottlenecks, inactive agents, and communication hotspots.
**Independent Test**: Navigate to dashboard, verify graph renders with data, switch time spans, confirm top-5 lists and counters update.
**Acceptance Scenarios**:
1. **Given** messages exist in the system, **When** the user navigates to the dashboard, **Then** a time-series bar/line chart displays message count over time with the default span (24h).
2. **Given** the dashboard is displayed, **When** the user clicks a time span button (1h, 4h, 24h, 7d, 1month), **Then** the chart and all statistics update to reflect only that time window.
3. **Given** 10 agents have sent messages, **When** viewing the dashboard, **Then** the "Top 5 Agents" section shows the 5 agents with the most messages in the selected time span, with message counts.
4. **Given** 20 channels have messages, **When** viewing the dashboard, **Then** the "Top 5 Channels" section shows the 5 channels with the most messages in the selected time span, with message counts.
5. **Given** the system has 8 channels and 12 agents, **When** viewing the dashboard, **Then** summary cards show "8 Channels" and "12 Agents".
6. **Given** no messages exist in the selected time span, **When** viewing the dashboard, **Then** the chart shows an empty state with "No messages in this period" and counters show zero.
---
### User Story 2 — PWA with Push Notifications (Priority: P1)
A human owner installs SynapBus as a PWA on their desktop or mobile device. They receive push notifications when high-priority DMs arrive or when agents mention them. The app works offline showing cached data and syncs when connectivity returns.
**Why this priority**: SynapBus is a messaging hub — users need instant awareness of agent activity without keeping a browser tab open. PWA installation provides native-app-like experience on all platforms.
**Independent Test**: Install the PWA on desktop Chrome, verify it launches as standalone app. Send a high-priority DM and verify push notification appears. Disconnect network, verify cached pages still load.
**Acceptance Scenarios**:
1. **Given** a user visits SynapBus in Chrome/Edge/Safari, **When** the browser detects the PWA manifest, **Then** an "Install" prompt is available (browser-native or custom banner).
2. **Given** the user has installed the PWA, **When** they launch it, **Then** it opens as a standalone window with SynapBus branding (no browser chrome).
3. **Given** the user enables push notifications, **When** a DM with priority >= 7 arrives, **Then** a push notification appears with sender name and message preview.
4. **Given** the user enables push notifications, **When** they are @mentioned in a channel, **Then** a push notification appears with channel name and mention context.
5. **Given** the user is offline, **When** they open the PWA, **Then** previously loaded pages render from cache with a "You are offline" indicator.
6. **Given** the PWA is open on a mobile device (< 768px viewport), **When** viewing any page, **Then** the layout adapts responsively (sidebar collapses to hamburger, content fills width).
7. **Given** the PWA is open on a tablet (768px-1024px), **When** viewing any page, **Then** the layout adapts with an appropriate intermediate layout.
---
### User Story 3 — Auto-Resizing Message Textarea (Priority: P1)
A user composes a long message in the ComposeForm. The textarea automatically grows to fit the content. When the text exceeds a maximum height, a scrollbar appears instead of the textarea continuing to grow infinitely.
**Why this priority**: The current fixed-height textarea is too small for multi-line messages, forcing users to scroll within a tiny box. This is the most common UX friction point.
**Independent Test**: Type progressively longer text in the compose form, verify the textarea grows. Paste a very long message, verify scrollbar appears at max height.
**Acceptance Scenarios**:
1. **Given** the compose form is empty, **When** the user starts typing, **Then** the textarea has a minimum height of 3 lines.
2. **Given** the user types multiple lines, **When** the content exceeds 3 lines, **Then** the textarea height grows to fit the content.
3. **Given** the textarea has grown, **When** the content exceeds 12 lines (approximately 240px), **Then** the textarea stops growing and a vertical scrollbar appears.
4. **Given** the user sends the message, **When** the textarea clears, **Then** it shrinks back to the minimum 3-line height.
---
### User Story 4 — Smart Mention/Channel Highlighting (Priority: P2)
When a message body contains @agentname or #channelname, the renderer checks if the referenced entity exists. Existing entities are highlighted as clickable links. Deleted entities show an "inactive" badge. Text that coincidentally contains @ or # symbols but doesn't reference any known entity is rendered as plain text.
**Why this priority**: Current highlighting blindly styles all @/# tokens, which creates confusing UI when entities are deleted or when text naturally contains these symbols (e.g., "issue #42" or "email@example.com").
**Independent Test**: Create a message with @existing-agent, @deleted-agent, @never-existed, #real-channel, #deleted-channel, and #random-text. Verify correct rendering for each case.
**Acceptance Scenarios**:
1. **Given** a message contains "@research-agent" and research-agent exists, **When** rendering, **Then** "@research-agent" is highlighted as a clickable link to the agent page.
2. **Given** a message contains "#bugs-synapbus" and the channel exists, **When** rendering, **Then** "#bugs-synapbus" is highlighted as a clickable link to the channel.
3. **Given** a message contains "@old-agent" and old-agent was deleted, **When** rendering, **Then** "@old-agent" shows with a small "inactive" badge/label.
4. **Given** a message contains "#archived-channel" and the channel was deleted, **When** rendering, **Then** "#archived-channel" shows with a small "inactive" badge/label.
5. **Given** a message contains "@nonexistent" and no agent by that name ever existed, **When** rendering, **Then** "@nonexistent" is rendered as plain text (no highlighting).
6. **Given** a message contains "email@example.com" or "issue #42", **When** rendering, **Then** these are rendered as plain text (not treated as mentions/channels).
---
### User Story 5 — Editable Agent Display Name (Priority: P2)
An owner views an agent's detail page and clicks on the agent's display name to edit it inline. The change is saved and reflected immediately across the UI.
**Why this priority**: Currently agent names can only be set at registration. Owners need to rename agents as their roles evolve.
**Independent Test**: Navigate to agent detail page, click display name, change it, verify it saves and updates in the sidebar/header.
**Acceptance Scenarios**:
1. **Given** a user is on the agent detail page, **When** they click the display name, **Then** it becomes an editable text input pre-filled with the current name.
2. **Given** the user has edited the name, **When** they press Enter or click away, **Then** the new name is saved via API and the UI updates.
3. **Given** the user edits the name to empty, **When** they try to save, **Then** validation prevents saving and shows an error.
4. **Given** the name was changed, **When** viewing the sidebar agent list, **Then** the updated name appears.
---
### User Story 6 — Editable Human Display Name in Settings (Priority: P2)
A human user opens Settings and edits their own display name. The change persists and is reflected in the header and anywhere the user's name appears.
**Why this priority**: Users should be able to personalize their identity in the system.
**Independent Test**: Go to Settings, change display name, verify it updates in the header.
**Acceptance Scenarios**:
1. **Given** the user navigates to Settings, **When** they see the account section, **Then** their current display name is shown in an editable field.
2. **Given** the user changes their display name and clicks Save, **When** the page reloads, **Then** the new name persists.
3. **Given** the user changes their name, **When** viewing the header, **Then** the updated name is shown.
---
### User Story 7 — Font Size Preference (Priority: P3)
A user opens Settings and adjusts the font size using -/+ controls. The change applies immediately across the entire UI and persists between sessions.
**Why this priority**: Accessibility feature — users with different vision needs or screen sizes benefit from adjustable text.
**Independent Test**: Go to Settings, click + to increase font size, verify all text across the app grows. Reload the page, verify the setting persists.
**Acceptance Scenarios**:
1. **Given** the user is on the Settings page, **When** they see the font size section, **Then** a -/+ control shows the current size (default: 16px).
2. **Given** the user clicks +, **When** the font size is less than 24px, **Then** the font size increases by 2px and the entire UI updates immediately.
3. **Given** the user clicks -, **When** the font size is greater than 12px, **Then** the font size decreases by 2px.
4. **Given** the user has set font size to 20px, **When** they close and reopen the app, **Then** the font size is still 20px.
5. **Given** the font size is at the minimum (12px), **When** the user clicks -, **Then** nothing happens (button appears disabled).
---
### User Story 8 — Version Display (Priority: P3)
A user sees the current SynapBus version (git tag) in the Web UI footer, along with a link to the GitHub repository. This helps with troubleshooting and identifying which version is deployed.
**Why this priority**: Essential for debugging but low user-facing value. Simple to implement.
**Independent Test**: Check the footer of any page, verify version string matches the deployed git tag and GitHub link is clickable.
**Acceptance Scenarios**:
1. **Given** SynapBus is built from git tag v0.7.0, **When** viewing any page, **Then** the footer shows "v0.7.0" with a link to the GitHub repository.
2. **Given** SynapBus is built from a commit without a tag, **When** viewing any page, **Then** the footer shows the short commit hash (e.g., "dev-abc1234").
3. **Given** the footer shows a version, **When** the user clicks the version text, **Then** a new tab opens to the GitHub repo releases page.
---
### User Story 9 — MCP Prompts (Priority: P2)
A developer using Claude Code or Gemini CLI with SynapBus MCP discovers pre-built prompts that help them accomplish common tasks: checking agent health, reviewing recent activity, summarizing channel conversations, and getting a daily digest.
**Why this priority**: MCP prompts improve developer UX by providing ready-made workflows for common agent management tasks.
**Independent Test**: Connect an MCP client, call `prompts/list`, verify prompts appear. Invoke a prompt, verify it returns a useful formatted response.
**Acceptance Scenarios**:
1. **Given** an MCP client connects, **When** it calls `prompts/list`, **Then** 3-5 prompts are listed with names and descriptions.
2. **Given** a user invokes the "daily-digest" prompt, **When** it executes, **Then** it returns a summary of recent activity including message counts, active agents, and notable events.
3. **Given** a user invokes the "agent-health-check" prompt, **When** it executes, **Then** it returns the status of all agents including last-seen times and pending message counts.
4. **Given** a user invokes the "channel-overview" prompt, **When** it executes, **Then** it returns a formatted list of channels with member counts and recent activity.
---
### User Story 10 — Website Messaging Update (Priority: P3)
The synapbus.dev website is updated to clearly communicate that SynapBus is a practical solution for individuals and small teams to build local agent networks. The site emphasizes human-agent collaboration through the Web UI on desktop and mobile.
**Why this priority**: Marketing/positioning update. Important for adoption but not a functional feature.
**Independent Test**: Visit synapbus.dev, verify messaging reflects individual/small team use case, agent collaboration narrative, and Web UI screenshots.
**Acceptance Scenarios**:
1. **Given** a visitor lands on synapbus.dev, **When** they read the hero section, **Then** the messaging emphasizes "practical local agent network for individuals and small teams".
2. **Given** the visitor scrolls, **When** they see feature sections, **Then** content highlights: agent collaboration, human-agent interaction via Web UI, desktop/mobile support.
3. **Given** the visitor views screenshots, **When** they see the Web UI images, **Then** screenshots show the analytics dashboard and mobile responsive views.
---
### Edge Cases
- What happens when the analytics time span has zero messages? → Empty state with informative message.
- What happens when push notification permission is denied? → Graceful degradation, no error. A small indicator in Settings shows notifications are disabled.
- What happens when an agent is deleted while viewing its detail page? → Redirect to agents list with a toast notification.
- What happens when the service worker cache is corrupted? → Force re-cache on next online visit.
- What happens when the user sets font size and clears browser storage? → Reverts to default 16px.
- What happens when @mention text contains special regex characters? → Escaping prevents rendering errors (e.g., "@agent++" renders as plain text).
- What happens when version info is not embedded at build time? → Footer shows "dev" as fallback.
## Requirements *(mandatory)*
### Functional Requirements
**Analytics Dashboard**
- **FR-001**: System MUST provide a REST endpoint returning message count aggregated by time bucket for a given time span.
- **FR-002**: System MUST provide a REST endpoint returning top N agents by message count for a given time span.
- **FR-003**: System MUST provide a REST endpoint returning top N channels by message count for a given time span.
- **FR-004**: System MUST provide a REST endpoint returning total agent count and total channel count.
- **FR-005**: Dashboard MUST display a time-series chart of messages with selectable spans: 1h, 4h, 24h, 7d, 1month.
- **FR-006**: Dashboard MUST display top 5 agents and top 5 channels for the selected time span.
- **FR-007**: Dashboard MUST display summary cards for total channels and total agents.
**PWA**
- **FR-008**: System MUST serve a valid web app manifest (manifest.json) with app name, icons, theme color, and display mode "standalone".
- **FR-009**: System MUST register a service worker that caches static assets for offline access.
- **FR-010**: System MUST support Web Push API for sending notifications to subscribed users.
- **FR-011**: System MUST provide endpoints for push subscription management (subscribe/unsubscribe).
- **FR-012**: System MUST send push notifications for DMs with priority >= 7 and @mentions.
- **FR-013**: PWA MUST be responsive across mobile (< 768px), tablet (768-1024px), and desktop (> 1024px) viewports.
**UX Fixes**
- **FR-014**: Compose textarea MUST auto-resize from a minimum of 3 lines to a maximum of 12 lines, then show scrollbar.
- **FR-015**: Agent display_name MUST be editable inline on the agent detail page.
- **FR-016**: Human user display_name MUST be editable on the Settings page.
- **FR-017**: Message renderer MUST validate @mentions and #channels against known entities: highlight existing, badge "inactive" for deleted, plain text for unknown.
- **FR-018**: Settings page MUST provide font size -/+ controls (12px to 24px, 2px steps) that apply globally and persist in localStorage.
**Version & Metadata**
- **FR-019**: System MUST expose a `/api/version` endpoint returning the build version (git tag or commit hash).
- **FR-020**: Web UI footer MUST display the version with a link to the GitHub repository.
**MCP Prompts**
- **FR-021**: MCP server MUST register 3-5 prompt resources discoverable via `prompts/list`.
- **FR-022**: Each prompt MUST return actionable, formatted text when invoked via `prompts/get`.
**Website**
- **FR-023**: Website hero and feature sections MUST communicate individual/small-team positioning and agent collaboration narrative.
### Key Entities
- **PushSubscription**: User ID, endpoint URL, auth key, p256dh key, created timestamp. One user can have multiple subscriptions (multi-device).
- **AnalyticsTimespan**: Enum of supported time windows (1h, 4h, 24h, 7d, 30d) used to parameterize analytics queries.
- **MCPPrompt**: Name, description, argument schema. Registered at server startup.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Dashboard loads and renders analytics within 2 seconds for time spans up to 1 month of data.
- **SC-002**: Users can install the PWA and receive push notifications on desktop Chrome, Edge, and Safari.
- **SC-003**: Compose textarea accommodates messages up to 500 lines without UX degradation.
- **SC-004**: Mention/channel highlighting correctly identifies 100% of existing, deleted, and non-existent entities in test scenarios.
- **SC-005**: Font size preference persists across browser sessions and applies to all text elements.
- **SC-006**: Version information is accurately displayed matching the deployed build.
- **SC-007**: MCP prompts are discoverable and return useful formatted responses for all registered prompts.
- **SC-008**: Website clearly communicates individual/small-team use case to new visitors.
+68
View File
@@ -0,0 +1,68 @@
# Tasks: SynapBus v0.7.0 — WebUI Analytics, PWA, UX Fixes, Website, MCP Prompts
**Branch**: `008-webui-pwa-analytics`
**Plan**: [plan.md](plan.md)
**Generated**: 2026-03-17
## Phase 1: Backend Analytics + Version API
- [x] **T-001**: Create `internal/api/analytics_handler.go` with analytics endpoints (timeline, top-agents, top-channels, summary)
- [x] **T-002**: Create `internal/api/version_handler.go` with GET /api/version endpoint
- [x] **T-003**: Register analytics and version routes in `internal/api/router.go`
- [x] **T-004**: Wire version string from `cmd/synapbus/main.go` to version handler
- [x] **T-005**: Write Go tests for analytics handler (table-driven, various spans, empty data)
- [x] **T-006**: Write Go tests for version handler
## Phase 2: Frontend Analytics Dashboard
- [x] **T-007**: Add analytics and version API client methods to `web/src/lib/api/client.ts`
- [x] **T-008**: Create `web/src/lib/components/AnalyticsChart.svelte` — SVG bar chart with time spans
- [x] **T-009**: Create `web/src/lib/components/TopList.svelte` — ranked list component
- [x] **T-010**: Redesign `web/src/routes/+page.svelte` dashboard with analytics, stat cards, span selector
- [x] **T-011**: Add version footer to `web/src/routes/+layout.svelte` with GitHub link
## Phase 3: UX Fixes — Textarea, Names, Font Size
- [x] **T-012**: Modify `web/src/lib/components/ComposeForm.svelte` — auto-resize textarea (min 3 lines, max 12 lines)
- [x] **T-013**: Add inline display_name editing to `web/src/routes/agents/[name]/+page.svelte`
- [x] **T-014**: Create `web/src/lib/stores/fontSize.ts` — font size store synced with localStorage
- [x] **T-015**: Add display name edit + font size -/+ controls to `web/src/routes/settings/+page.svelte`
- [x] **T-016**: Create `PUT /api/auth/profile` endpoint in Go for human display name editing
- [x] **T-017**: Apply font size CSS custom property in `web/src/routes/+layout.svelte`
## Phase 4: Smart Mention/Channel Highlighting
- [x] **T-018**: Create `web/src/lib/stores/entities.ts` — cached agent/channel lists for mention validation
- [x] **T-019**: Modify `web/src/lib/components/MessageBody.svelte` — smart mention/channel highlighting with inactive badges
## Phase 5: PWA — Manifest, Service Worker, Push Notifications
- [x] **T-020**: Create `schema/012_push_subscriptions.sql` migration
- [x] **T-021**: Create `internal/push/service.go` — VAPID key management, Web Push sending
- [x] **T-022**: Create `internal/push/store.go` — SQLite push subscription CRUD
- [x] **T-023**: Create `internal/api/push_handler.go` — subscribe, unsubscribe, VAPID key endpoints
- [x] **T-024**: Register push routes in `internal/api/router.go` and wire in `cmd/synapbus/main.go`
- [x] **T-025**: Create `web/static/manifest.json` PWA manifest with icons
- [x] **T-026**: Create `web/static/sw.js` service worker (cache-first static, network-only API)
- [x] **T-027**: Register service worker in layout + add push notification toggle to Settings
- [x] **T-028**: Integrate push sending into message delivery for priority >= 7 DMs and @mentions
- [x] **T-029**: Write Go tests for push service and store
- [x] **T-030**: Generate PWA icons (192x192, 512x512)
## Phase 6: MCP Prompts
- [x] **T-031**: Create `internal/mcp/prompts.go` with 4 prompts (daily-digest, agent-health-check, channel-overview, debug-agent)
- [x] **T-032**: Register prompts in `internal/mcp/server.go`
- [x] **T-033**: Write Go tests for MCP prompts
## Phase 7: Website Update
- [ ] **T-034**: Update `~/repos/synapbus-website/` hero and feature messaging for individual/small-team positioning
- [ ] **T-035**: Add/update screenshots showing analytics dashboard and mobile views
## Phase 8: Integration Testing & Polish
- [x] **T-036**: Run full test suite (`make test`), fix any failures
- [x] **T-037**: Build and verify end-to-end (`make build && ./bin/synapbus serve`)
- [ ] **T-038**: Manual verification of all features via browser and curl
- [ ] **T-039**: Create git tag v0.7.0
@@ -0,0 +1,37 @@
# Specification Quality Checklist: Attachments & Threads Enhancement
**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
- All items pass validation. Spec is ready for `/speckit.plan`.
- Assumptions section documents all decisions made where details were ambiguous.
- 6 user stories cover: web UI upload, image preview/fullscreen, thread visibility, agent attachments, agent threads, admin backup/restore.
- 18 functional requirements, 8 success criteria, 7 edge cases documented.
@@ -0,0 +1,100 @@
# REST API Contract Changes
## Modified Endpoints
### POST /api/messages — Send Message (Modified)
**Request Body** (new field: `attachments`):
```json
{
"to": "agent-name",
"channel": "channel-name",
"body": "Message text",
"subject": "Optional subject",
"priority": 5,
"reply_to": 123,
"attachments": ["sha256hash1", "sha256hash2"]
}
```
**Response** (enriched with attachments and reply_count):
```json
{
"id": 456,
"conversation_id": 789,
"from_agent": "sender",
"body": "Message text",
"reply_to": 123,
"reply_count": 0,
"attachments": [
{
"hash": "sha256hash1",
"original_filename": "image.png",
"size": 204800,
"mime_type": "image/png",
"is_image": true
}
],
"created_at": "2026-03-17T10:00:00Z"
}
```
### GET /api/messages — List Messages (Modified Response)
Each message in the array now includes `reply_count` (integer) and `attachments` (array).
### GET /api/channels/{name}/messages — Channel Messages (Modified Response)
Each message includes `reply_count` and `attachments`.
### GET /api/messages/{id}/replies — Thread Replies (Existing, Unchanged)
Returns array of reply messages.
## Existing Endpoints (Unchanged)
### POST /api/attachments — Upload
Multipart form upload. Returns hash, size, mime_type, filename.
### GET /api/attachments/{hash} — Download
Streams file content with Content-Type header.
### GET /api/attachments/{hash}/meta — Metadata
Returns attachment metadata JSON.
## MCP Tool Changes
### send_message (Modified)
New parameter: `attachments` — array of SHA-256 hashes of previously uploaded attachments.
Updated description for `reply_to`: "ID of the parent message to reply to. Creates a threaded reply. Always use reply_to when responding to a message that was itself a thread reply, to keep the conversation organized."
### upload_attachment (Existing)
No changes. Parameters: content (base64), filename, mime_type, message_id.
### download_attachment (Existing)
No changes. Parameter: hash.
## Admin CLI Changes
### New: `synapbus attachments backup`
```
synapbus attachments backup --output /path/to/backup.tar.gz [--socket /path/to/socket]
```
Creates tar.gz of attachment storage directory.
### New: `synapbus attachments restore`
```
synapbus attachments restore --input /path/to/backup.tar.gz [--socket /path/to/socket]
```
Restores attachment files, skipping existing (dedup-safe).
+119
View File
@@ -0,0 +1,119 @@
# Data Model: Attachments & Threads Enhancement
**Branch**: `009-attachments-threads` | **Date**: 2026-03-17
## Entity Changes
### Message (Modified)
Existing table `messages` — already has `reply_to` column via migration 007.
**New computed field** (not stored, calculated in queries):
- `reply_count` (integer): COUNT of messages where reply_to = this message's ID
**New field in API response** (joined from attachments table):
- `attachments` (array of Attachment): All attachments linked to this message via message_id FK
### Attachment (Existing, No Schema Changes)
Table `attachments` — no changes needed. Existing schema:
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| hash | TEXT NOT NULL | SHA-256 content hash |
| original_filename | TEXT NOT NULL | Original upload filename |
| size | INTEGER NOT NULL | File size in bytes |
| mime_type | TEXT NOT NULL | Detected MIME type |
| message_id | INTEGER FK | Nullable, references messages(id) |
| uploaded_by | TEXT NOT NULL | Agent/user who uploaded |
| created_at | TIMESTAMP | Upload timestamp |
### Relationships
```
Message 1 ──── 0..* Attachment (via attachment.message_id)
Message 1 ──── 0..* Message (via message.reply_to → parent message.id)
```
## Query Changes
### Message List with Reply Count
```sql
SELECT m.*,
COALESCE(rc.reply_count, 0) as reply_count
FROM messages m
LEFT JOIN (
SELECT reply_to, COUNT(*) as reply_count
FROM messages
WHERE reply_to IS NOT NULL
GROUP BY reply_to
) rc ON rc.reply_to = m.id
WHERE ...
```
### Message with Attachments
```sql
SELECT a.id, a.hash, a.original_filename, a.size, a.mime_type, a.created_at
FROM attachments a
WHERE a.message_id = ?
ORDER BY a.created_at ASC
```
### Thread Replies
Already exists in store.go:
```sql
SELECT ... FROM messages WHERE reply_to = ? ORDER BY created_at ASC
```
## API Response Changes
### Message Response (enriched)
```json
{
"id": 123,
"body": "Hello",
"from_agent": "research-bot",
"reply_to": null,
"reply_count": 3,
"attachments": [
{
"hash": "abc123...",
"original_filename": "report.pdf",
"size": 1048576,
"mime_type": "application/pdf",
"is_image": false
}
]
}
```
### Send Message Request (enriched)
```json
{
"to": "agent-name",
"body": "See attached report",
"reply_to": 456,
"attachments": ["abc123...", "def456..."]
}
```
## File Storage (No Changes)
Content-addressable storage structure (existing):
```
{dataDir}/attachments/
├── ab/
│ └── cd/
│ └── abcd1234... (full SHA-256 hash as filename)
├── ef/
│ └── 01/
│ └── ef012345...
```
Backup archive mirrors this structure in tar.gz format.
+106
View File
@@ -0,0 +1,106 @@
# Implementation Plan: Attachments & Threads Enhancement
**Branch**: `009-attachments-threads` | **Date**: 2026-03-17 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/009-attachments-threads/spec.md`
## Summary
Enhance SynapBus with web UI file attachment support (upload, thumbnail preview, fullscreen view, download), fix thread visibility (reply count badges, thread indicators), update MCP tools for agent attachment/thread workflows, and add admin CLI backup/restore for attachment files. The attachment backend (CAS, SQLite metadata) already exists; this feature adds the UI layer, enriches API responses, and fills thread visibility gaps.
## Technical Context
**Language/Version**: Go 1.25+ (backend), Svelte 5 + Tailwind (frontend)
**Primary Dependencies**: go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), spf13/cobra (CLI)
**Storage**: SQLite (modernc.org/sqlite, pure Go) + content-addressable filesystem (SHA-256)
**Testing**: `go test ./...` (backend), manual + curl (API), Chrome (UI)
**Target Platform**: linux/amd64, darwin/arm64 (cross-compiled single binary)
**Project Type**: Web service with embedded SPA
**Performance Goals**: File uploads complete in <10s for files under 10MB; thumbnails render in <1s
**Constraints**: Zero CGO, single binary, single --data directory
**Scale/Scope**: LAN-scale (tens of agents, hundreds of messages/day)
## 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 changes are within the single binary. No external dependencies added. |
| II. MCP-Native | PASS | Agent attachment/thread workflows use MCP tools. REST API changes are for Web UI only. |
| III. Pure Go, Zero CGO | PASS | No new CGO dependencies. Image thumbnails are CSS-only (client-side). Backup uses stdlib archive/tar + compress/gzip. |
| IV. Multi-Tenant with Ownership | PASS | Attachments track uploaded_by. Message ownership unchanged. |
| V. Embedded OAuth 2.1 | N/A | No auth changes. |
| VI. Semantic-Ready Storage | N/A | No search changes. |
| VII. Swarm Intelligence | N/A | No swarm pattern changes. |
| VIII. Observable by Default | PASS | Attachment uploads and thread replies are already traced. |
| IX. Progressive Complexity | PASS | Attachments are tier 3 (advanced). Thread fixes improve existing basic messaging. |
| X. Web UI as First-Class Citizen | PASS | This feature specifically enhances the Web UI. |
**Post-design re-check**: All principles still satisfied. No violations.
## Project Structure
### Documentation (this feature)
```text
specs/009-attachments-threads/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Research decisions
├── data-model.md # Data model changes
├── quickstart.md # Testing quickstart
├── contracts/ # API contract changes
│ └── rest-api.md
├── checklists/
│ └── requirements.md
└── tasks.md # Task breakdown (generated by /speckit.tasks)
```
### Source Code (repository root)
```text
# Backend (Go)
internal/
├── attachments/
│ └── service.go # Add file type validation
├── messaging/
│ ├── store.go # Add reply_count to queries, attachment loading
│ ├── service.go # Add attachment linking on send
│ └── types.go # Add ReplyCount, Attachments fields to Message
├── api/
│ ├── messages_handler.go # Accept attachments[] in send, return enriched messages
│ └── attachments_handler.go # (existing, minimal changes)
└── mcp/
├── tools_hybrid.go # Add attachments param, update descriptions
└── bridge.go # Handle attachments in send flow
cmd/synapbus/
└── admin.go # Add backup/restore subcommands
# Frontend (Svelte)
web/src/lib/
├── components/
│ ├── ComposeForm.svelte # Add attachment upload button + preview
│ ├── MessageList.svelte # Add thread indicator badges
│ ├── MessageBody.svelte # Add attachment display (thumbnails, file icons)
│ ├── AttachmentPreview.svelte # NEW: thumbnail + fullscreen component
│ └── ThreadPanel.svelte # Existing, minor fixes
├── stores/
│ └── thread.ts # Existing, no changes
└── api/
└── client.ts # Add attachment upload method
# Tests
internal/attachments/service_test.go # File type validation tests
internal/messaging/store_test.go # Reply count query tests
internal/messaging/service_test.go # Attachment linking tests
internal/api/messages_handler_test.go # API enrichment tests
internal/mcp/tools_hybrid_test.go # MCP attachment param tests
cmd/synapbus/admin_test.go # Backup/restore tests
```
**Structure Decision**: Existing Go + Svelte project structure. No new packages needed — all changes extend existing packages. One new Svelte component (AttachmentPreview.svelte) for image thumbnail/fullscreen display.
## Complexity Tracking
No constitution violations. No complexity justifications needed.
+126
View File
@@ -0,0 +1,126 @@
# Quickstart: Attachments & Threads Enhancement
## Prerequisites
- Go 1.23+ installed
- Node.js 18+ for Svelte UI development
- Running SynapBus instance with data directory
## Build & Test
```bash
# Build everything
make build
make web
# Run tests
make test
# Run with hot reload for development
make dev
```
## Testing Attachments
### Upload via curl
```bash
# Upload a file
curl -X POST http://localhost:8080/api/attachments \
-H "Cookie: session=YOUR_SESSION" \
-F "file=@/path/to/image.png"
# Response: {"hash": "abc123...", "size": 204800, "mime_type": "image/png", "original_filename": "image.png"}
```
### Send message with attachment
```bash
curl -X POST http://localhost:8080/api/messages \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{
"channel": "general",
"body": "Check this image",
"attachments": ["abc123..."]
}'
```
### Download attachment
```bash
curl http://localhost:8080/api/attachments/abc123... -o downloaded.png
```
## Testing Threads
### Send a reply
```bash
# Send initial message
curl -X POST http://localhost:8080/api/messages \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"channel": "general", "body": "Original message"}'
# Reply to message ID 123
curl -X POST http://localhost:8080/api/messages \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"channel": "general", "body": "Thread reply", "reply_to": 123}'
```
### View thread replies
```bash
curl http://localhost:8080/api/messages/123/replies \
-H "Cookie: session=YOUR_SESSION"
```
## Testing via MCP
### Upload attachment (MCP)
```json
{
"action": "upload_attachment",
"args": {
"content": "<base64-encoded-data>",
"filename": "report.pdf",
"mime_type": "application/pdf"
}
}
```
### Send message with attachment and reply_to (MCP)
```json
{
"tool": "send_message",
"args": {
"channel": "general",
"body": "See attached report",
"reply_to": 123,
"attachments": ["abc123..."]
}
}
```
## Admin Backup/Restore
```bash
# Backup attachments
./synapbus attachments backup --output /backup/attachments-2026-03-17.tar.gz
# Restore attachments
./synapbus attachments restore --input /backup/attachments-2026-03-17.tar.gz
```
## Web UI
1. Open http://localhost:8080 and log in
2. Navigate to a channel
3. Click the paperclip icon in the compose area to attach a file
4. Send a message — attached images show as thumbnails
5. Click a thumbnail to see fullscreen view
6. Messages with replies show "N replies" badge — click to open thread panel
+70
View File
@@ -0,0 +1,70 @@
# Research: Attachments & Threads Enhancement
**Branch**: `009-attachments-threads` | **Date**: 2026-03-17
## Decision 1: Migration Location for reply_to
**Decision**: Migration `007_threads.sql` already exists in `internal/storage/schema/` adding `reply_to INTEGER` column to messages table with an index. No new migration needed for the column itself.
**Rationale**: The Go migration loader reads from `internal/storage/schema/` (embedded via go:embed). The `schema/` directory at repo root is for documentation/reference. The column already exists in the running system.
**What's missing**: Reply count is not returned in message list API responses. Need to add `reply_count` as a computed field (COUNT subquery or LEFT JOIN) to message queries so the UI can show thread indicators.
## Decision 2: Attachment-Message Linking Flow
**Decision**: Use a two-step flow: (1) upload attachment → get hash, (2) send message with `attachments` field containing hash array. The `AttachToMessage` service method links attachments after message creation.
**Rationale**: The existing upload endpoint returns a hash. The existing `AttachToMessage(hash, messageID)` method exists but is not exposed in REST API. Need to:
1. Add `attachments` field to the send message API request
2. After message insert, call `AttachToMessage` for each hash
3. Include attachments in message API responses (query by message_id)
**Alternatives considered**:
- Single multipart upload with message: Too complex for MCP agents, breaks the existing API pattern
- Store attachment hashes in message metadata JSON: Loses relational integrity, harder to query
## Decision 3: Thumbnail Generation
**Decision**: Client-side only. Use CSS `max-width: 200px; max-height: 200px; object-fit: cover` on `<img>` tags pointing to the attachment download URL.
**Rationale**: Server-side thumbnail generation would require image processing libraries (likely CGO, violating Principle III). The original images are served directly; the browser handles resizing. For large images, this means downloading the full file for thumbnails — acceptable for a local-first system on LAN.
**Alternatives considered**:
- Server-side thumbnails with pure Go image library: Adds complexity, new dependency, storage overhead
- Lazy loading with IntersectionObserver: Good optimization to add but not core requirement
## Decision 4: Fullscreen Image Overlay
**Decision**: Simple Svelte component with fixed overlay, `<img>` at natural size (max viewport), close button, download button. Dismiss via Escape, click outside, or close button.
**Rationale**: Minimal implementation that meets requirements. No carousel needed (one image at a time). No zoom/pan needed for v1.
## Decision 5: Admin Backup/Restore
**Decision**: `synapbus attachments backup --output path.tar.gz` and `synapbus attachments restore --input path.tar.gz`. Archive the CAS directory structure preserving the 2-level shard paths.
**Rationale**: The CAS directory is self-contained (`{dataDir}/attachments/`). tar.gz preserves directory structure and is universally supported. Restore skips existing files (same hash = same content = dedup).
**Alternatives considered**:
- Backup via admin socket: Useful for remote backup but adds complexity; CLI direct file access is simpler
- Include SQLite metadata in backup: Not needed — metadata can be reconstructed from CAS files + existing DB
## Decision 6: Thread Visibility in UI
**Decision**: Add reply_count to message list responses. Show a clickable "N replies" badge below messages. Clicking opens ThreadPanel. ThreadPanel already exists and works (loads conversation, supports replying).
**Rationale**: The ThreadPanel already handles thread display and replying. The missing piece is the reply_count in the message list so users know threads exist. The "View thread" button exists in MessageList but only on hover — need to make it always visible when reply_count > 0.
## Decision 7: MCP Tool Descriptions
**Decision**: Update `send_message` tool description to document:
1. `reply_to` parameter: "ID of the parent message to reply to. Creates a threaded reply. Always use this when responding to a message that is part of a thread."
2. `attachments` parameter (new): "Array of attachment hashes to link to this message. Upload attachments first using the upload_attachment action."
**Rationale**: Agents need clear tool descriptions to know how to use threading and attachments. The current reply_to description is minimal.
## Decision 8: File Type Validation
**Decision**: Validate MIME type on upload against an allowlist. Supported: image/*, application/pdf, text/*. Reject everything else with a clear error message listing supported types.
**Rationale**: Security best practice — don't allow arbitrary file uploads. The existing MIME detection in `mime.go` already handles type detection; just need to add validation in the upload handler.
+174
View File
@@ -0,0 +1,174 @@
# Feature Specification: Attachments & Threads Enhancement
**Feature Branch**: `009-attachments-threads`
**Created**: 2026-03-17
**Status**: Draft
**Input**: User description: "Add file attachment support to web UI and MCP tools, fix thread visibility issues, add admin CLI backup/restore for attachments"
## Assumptions
- Max file size: 50 MB (already enforced in existing backend)
- Supported attachment types: images (jpg, png, gif, webp, svg), PDFs, text files (txt, md, csv, json, xml, yaml, log)
- Image thumbnails: 200x200px maximum CSS constraint, rendered client-side from original image (no server-side thumbnail generation)
- Fullscreen image viewer: CSS overlay with close button and download link, dismissed by clicking outside or pressing Escape
- Thread indicator: Badge on parent message showing reply count; clicking it opens the thread panel
- `reply_to` column: nullable integer foreign key referencing `messages(id)` in a new migration
- Attachment backup format: tar.gz archive of the content-addressable storage directory
- MCP tool descriptions will be updated to clearly document `reply_to` and attachment parameters
- Attachment upload in web UI uses multipart/form-data to the existing POST /api/attachments endpoint
- Multiple attachments per message are supported (existing schema allows this via message_id FK)
- Agents attach files by providing base64-encoded content via the existing `upload_attachment` MCP action, then referencing the hash when sending a message
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Attach Files from Web UI (Priority: P1)
A user composing a message in the web UI wants to attach one or more files (images, PDFs, text files) to their message. They click an attachment button in the compose form, select files from their device, see upload progress, and the attachments are linked to the sent message.
**Why this priority**: This is the core feature request. Without web UI attachment support, users cannot share files through the platform's primary interface.
**Independent Test**: Can be fully tested by logging into the web UI, composing a message with an attached file, sending it, and verifying the attachment appears on the sent message.
**Acceptance Scenarios**:
1. **Given** a logged-in user on a channel page, **When** they click the attachment button and select a 5MB PNG image, **Then** the file uploads successfully and appears as a preview in the compose area before sending.
2. **Given** a user with a file selected for attachment, **When** they send the message, **Then** the message is sent with the attachment linked, and the attachment appears in the message view.
3. **Given** a user selecting a file larger than 50MB, **When** they attempt to upload, **Then** the system rejects the upload with a clear error message indicating the size limit.
4. **Given** a user selecting an unsupported file type (e.g., .exe), **When** they attempt to upload, **Then** the system rejects the upload with a clear error message about supported types.
---
### User Story 2 - View Image Attachments with Thumbnails and Fullscreen (Priority: P1)
When viewing messages that have image attachments, users see a thumbnail preview inline with the message. Clicking the thumbnail opens a fullscreen overlay. Users can also download the attachment.
**Why this priority**: Equally critical to uploading — without display, attachments have no value to recipients.
**Independent Test**: Can be tested by viewing a message with an image attachment and verifying the thumbnail renders, fullscreen opens on click, and the download link works.
**Acceptance Scenarios**:
1. **Given** a message with a PNG image attachment, **When** the message is displayed, **Then** a thumbnail (max 200x200px) appears inline below the message body.
2. **Given** a visible thumbnail, **When** the user clicks it, **Then** a fullscreen overlay displays the full-resolution image with a close button and download button.
3. **Given** a fullscreen overlay is open, **When** the user clicks the close button, presses Escape, or clicks outside the image, **Then** the overlay closes.
4. **Given** a message with a PDF attachment, **When** the message is displayed, **Then** a file icon with the filename appears, and clicking it downloads the file.
5. **Given** a message with a text file attachment, **When** the message is displayed, **Then** a file icon with the filename appears, and clicking it downloads the file.
---
### User Story 3 - Thread Visibility and Reply Indicators (Priority: P1)
Messages that have thread replies must show a visible indicator with the reply count. Users can click the indicator to open the thread panel and see all replies. New replies in a thread must be visible.
**Why this priority**: Thread responses are currently invisible, making conversations broken. This is a critical usability fix.
**Independent Test**: Can be tested by sending a reply to a message, then viewing the parent message and verifying the reply count badge appears and clicking it opens the thread with the reply visible.
**Acceptance Scenarios**:
1. **Given** a message with 3 thread replies, **When** the message is displayed in the message list, **Then** a thread indicator shows "3 replies" below the message.
2. **Given** a message with a thread indicator, **When** the user clicks it, **Then** the thread panel opens showing all replies in chronological order.
3. **Given** an open thread panel, **When** a new reply is posted to that thread, **Then** the new reply appears in the panel without requiring a page refresh.
4. **Given** a message without any replies, **When** it is displayed, **Then** no thread indicator is shown.
---
### User Story 4 - Agents Attach Files via MCP Tools (Priority: P2)
AI agents can attach files (images, PDFs, text) to messages they send via MCP tools. The agent uploads the file first, gets a reference hash, then includes the attachment reference when sending a message.
**Why this priority**: Agents are primary users of SynapBus; they need file sharing capability, but the web UI must work first as it enables human verification.
**Independent Test**: Can be tested by using MCP tools to upload a file and send a message with the attachment hash, then verifying the attachment appears on the message.
**Acceptance Scenarios**:
1. **Given** an authenticated agent, **When** it calls `upload_attachment` with base64-encoded image content, **Then** the file is stored and a hash is returned.
2. **Given** an agent with an uploaded attachment hash, **When** it sends a message with the `attachments` parameter containing the hash, **Then** the message is created with the attachment linked.
3. **Given** an agent sending a message with an invalid attachment hash, **Then** the system returns an error indicating the attachment was not found.
---
### User Story 5 - Agents Reply in Threads via MCP (Priority: P2)
When an agent receives a message that is part of a thread (has a `reply_to` context), the agent's response must be sent as a reply in the same thread. MCP tool descriptions clearly document this behavior.
**Why this priority**: Ensures agent conversations stay organized in threads rather than creating disconnected top-level messages.
**Independent Test**: Can be tested by sending a threaded message to an agent via MCP, having the agent reply with `reply_to`, and verifying the reply appears in the thread.
**Acceptance Scenarios**:
1. **Given** an agent receiving a DM that is a thread reply, **When** the message metadata includes the thread context, **Then** the agent can see the `reply_to` field identifying the parent message.
2. **Given** an agent composing a reply to a threaded message, **When** it calls `send_message` with the `reply_to` parameter set to the parent message ID, **Then** the reply is stored as part of that thread.
3. **Given** MCP tool documentation, **When** an agent reads the `send_message` tool description, **Then** the `reply_to` parameter is clearly documented with usage guidance.
---
### User Story 6 - Admin Backup and Restore Attachments (Priority: P3)
System administrators can backup all attachment files to a tar.gz archive and restore them from such an archive, using CLI commands. This enables disaster recovery and migration.
**Why this priority**: Important for operational safety but not required for day-to-day feature usage.
**Independent Test**: Can be tested by running `synapbus attachments backup` to create an archive, deleting the attachment directory, running `synapbus attachments restore`, and verifying all files are recovered.
**Acceptance Scenarios**:
1. **Given** a data directory with stored attachments, **When** the admin runs `synapbus attachments backup --output /path/to/backup.tar.gz`, **Then** a tar.gz archive is created containing all attachment files.
2. **Given** a valid backup archive, **When** the admin runs `synapbus attachments restore --input /path/to/backup.tar.gz`, **Then** all attachment files are restored to the data directory.
3. **Given** a restore operation on a directory with existing files, **When** the restore runs, **Then** existing files with the same hash are skipped (deduplication preserved), and only missing files are added.
---
### Edge Cases
- What happens when a user uploads a file with the same content (same SHA-256 hash) as an existing attachment? The system deduplicates and reuses the existing stored file.
- What happens when a message with attachments is deleted? The attachment metadata link is removed, but the file remains until garbage collection determines it is orphaned (no remaining references).
- What happens when a thread's parent message is deleted? Thread replies become orphaned; they remain accessible but the thread indicator disappears. The reply_to FK should use SET NULL on delete.
- What happens when an agent uploads a file exceeding 50MB via MCP? The upload is rejected with an error message indicating the size limit.
- What happens when the attachment storage directory is full or write-protected? The upload fails with a clear error message, and the message can still be sent without the attachment.
- What happens when a user tries to download an attachment whose file is missing from disk? The system returns a 404 error with a message indicating the file is unavailable.
- What happens when deeply nested thread replies occur? reply_to always points to the immediate parent message; the UI displays a flat list of all replies in chronological order within the thread panel.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST allow users to attach files (images, PDFs, text files) when composing messages in the web UI
- **FR-002**: System MUST enforce a maximum file size of 50 MB per attachment
- **FR-003**: System MUST restrict uploads to supported file types: images (jpg, png, gif, webp, svg), PDFs, and text files (txt, md, csv, json, xml, yaml, log)
- **FR-004**: System MUST display inline thumbnail previews (max 200x200px) for image attachments in message views
- **FR-005**: System MUST provide a fullscreen overlay when a user clicks an image thumbnail, with close (click outside, Escape key, close button) and download functionality
- **FR-006**: System MUST display non-image attachments (PDFs, text files) as file icons with filename and a download link
- **FR-007**: System MUST store attachments in a content-addressable filesystem with SHA-256 deduplication, storing only metadata references in the database
- **FR-008**: System MUST support the `reply_to` field on messages, storing a nullable reference to the parent message ID
- **FR-009**: System MUST display a thread indicator (reply count badge) on messages that have one or more replies
- **FR-010**: System MUST allow users to open a thread panel showing all replies when clicking the thread indicator
- **FR-011**: System MUST allow agents to upload attachments via MCP tools using base64-encoded content
- **FR-012**: System MUST allow agents to link uploaded attachments to messages sent via MCP tools
- **FR-013**: System MUST allow agents to send threaded replies via MCP tools using the `reply_to` parameter
- **FR-014**: MCP tool descriptions MUST clearly document the `reply_to` parameter and attachment workflow
- **FR-015**: System MUST provide an admin CLI command to backup all attachment files to a tar.gz archive
- **FR-016**: System MUST provide an admin CLI command to restore attachment files from a tar.gz archive with deduplication
- **FR-017**: System MUST return the reply count for each message in API responses to enable thread indicators
- **FR-018**: System MUST allow downloading attachments via a direct URL using the content hash
### Key Entities
- **Attachment**: A file stored in the content-addressable filesystem. Key attributes: content hash (unique identifier), original filename, file size, MIME type, uploader, creation timestamp. Related to messages via a foreign key.
- **Message**: A communication unit between agents or in channels. Extended with: reply_to (nullable reference to parent message), reply_count (derived count of replies). Related to attachments (one-to-many).
- **Thread**: A logical grouping of messages linked by reply_to references. Not a separate entity — derived from message relationships. The root message is the one with no reply_to; all messages with reply_to pointing to it (directly or transitively) form the thread.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can attach and send a file in under 10 seconds for files under 10 MB
- **SC-002**: Image thumbnails render within 1 second of message display
- **SC-003**: 100% of thread replies are visible when opening a thread panel (zero invisible replies)
- **SC-004**: Agents can upload and attach files to messages in a single workflow (upload then send)
- **SC-005**: Thread reply count is accurate and updates immediately when new replies are added
- **SC-006**: Admin backup captures all stored attachment files; restore recovers 100% of backed-up files
- **SC-007**: All attachment operations (upload, download, preview) work for all supported file types
- **SC-008**: MCP tool descriptions are self-documenting — an agent can understand attachment and thread workflows from tool descriptions alone
+159
View File
@@ -0,0 +1,159 @@
# Tasks: Attachments & Threads Enhancement
**Input**: Design documents from `/specs/009-attachments-threads/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
**Tests**: Included (explicitly requested in feature description)
**Organization**: Tasks grouped by user story for independent implementation and testing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Setup
**Purpose**: No new project setup needed — extending existing codebase. Verify current state.
- [x] T001 Verify migration 007_threads.sql exists and is applied in internal/storage/schema/007_threads.sql
- [x] T002 Run `make test` to confirm existing tests pass before changes
**Checkpoint**: Baseline verified — proceed with foundational changes
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Backend data model changes that ALL user stories depend on
**CRITICAL**: No user story work can begin until this phase is complete
- [x] T003 Add ReplyCount and Attachments fields to Message struct in internal/messaging/types.go
- [x] T004 Add GetReplyCounts batch query method to store in internal/messaging/store.go
- [x] T005 Add AttachmentLinker interface and EnrichMessages method to wire attachment store into message service in internal/messaging/service.go
- [x] T006 Add AttachToMessage call in SendMessage flow — accept attachment hashes, link after message insert in internal/messaging/service.go
- [x] T007 Add file type validation (allowlist: image/*, application/pdf, text/*) to upload in internal/attachments/service.go
- [x] T008 Write tests for reply_count queries in internal/messaging/store_test.go
- [x] T009 Write tests for attachment linking in SendMessage in internal/messaging/service_test.go
- [x] T010 Write tests for file type validation in internal/attachments/service_test.go
**Checkpoint**: Foundation ready — message API returns reply_count and attachments, file type validation enforced
---
## Phase 3: User Story 1 — Attach Files from Web UI (Priority: P1)
**Goal**: Users can attach files in the compose form and send messages with attachments
**Independent Test**: Log in to web UI, compose message with attached file, send, verify attachment appears
### Implementation for User Story 1
- [x] T011 [US1] Accept `attachments` (array of hashes) in send message API request and link them after message creation in internal/api/messages_handler.go
- [x] T012 [US1] Enrich message API responses with attachments array (query by message_id) in internal/api/messages_handler.go
- [x] T013 [US1] Add `uploadAttachment` method to API client (multipart form upload) in web/src/lib/api/client.ts
- [x] T014 [US1] Add attachment upload button, file picker, upload progress, and preview chips to ComposeForm in web/src/lib/components/ComposeForm.svelte
- [x] T015 [US1] Send attachment hashes with message when submitting compose form in web/src/lib/components/ComposeForm.svelte
**Checkpoint**: Users can attach and send files from web UI
---
## Phase 4: User Story 2 — View Image Attachments with Thumbnails and Fullscreen (Priority: P1)
**Goal**: Messages display image thumbnails inline; click opens fullscreen; non-image attachments show file icon with download
**Independent Test**: View a message with image attachment, verify thumbnail renders, click for fullscreen, download works
### Implementation for User Story 2
- [x] T016 [US2] Create AttachmentPreview.svelte component with image thumbnail (200x200 CSS), fullscreen overlay, close/download buttons in web/src/lib/components/AttachmentPreview.svelte
- [x] T017 [US2] Integrate attachment display into MessageList.svelte — render AttachmentPreview for images, file icons for others in web/src/lib/components/MessageList.svelte
- [x] T018 [US2] Add keyboard handler for Escape key to close fullscreen overlay in web/src/lib/components/AttachmentPreview.svelte
- [x] T019 [US2] Add attachment display to ThreadPanel.svelte for thread messages in web/src/lib/components/ThreadPanel.svelte
**Checkpoint**: Attachment display fully functional in web UI
---
## Phase 5: User Story 3 — Thread Visibility and Reply Indicators (Priority: P1)
**Goal**: Messages with replies show reply count badge; clicking opens thread panel; thread replies visible
**Independent Test**: Send reply to a message, verify parent shows "N replies" badge, click to open thread panel
### Implementation for User Story 3
- [x] T020 [US3] Ensure all message handlers return reply_count in response JSON via EnrichMessages in internal/api/messages_handler.go
- [x] T021 [US3] Add always-visible thread indicator badge ("N replies") to MessageList when reply_count > 0 in web/src/lib/components/MessageList.svelte
- [x] T022 [US3] Wire thread indicator click to openThread() to open ThreadPanel in web/src/lib/components/MessageList.svelte
**Checkpoint**: Thread visibility fixed — users can see and navigate thread replies
---
## Phase 6: User Story 4 — Agents Attach Files via MCP Tools (Priority: P2)
**Goal**: Agents upload files and link them to messages via MCP tools
**Independent Test**: Use MCP execute tool to upload_attachment, then send_message with attachments, verify linked
### Implementation for User Story 4
- [x] T023 [US4] Add `attachments` parameter (comma-separated hashes) to send_message MCP tool schema in internal/mcp/tools_hybrid.go
- [x] T024 [US4] Handle attachments parameter in handleSendMessage — parse hashes, pass to service in internal/mcp/tools_hybrid.go
- [x] T025 [US4] Handle attachments in bridge.go callSendMessage and callSendChannelMessage in internal/mcp/bridge.go
- [x] T026 [US4] Include attachments in MCP message responses via EnrichMessages in internal/mcp/tools_hybrid.go
- [x] T027 [US4] Update BroadcastMessage signature to accept attachments in internal/channels/service.go
**Checkpoint**: Agents can attach files to messages via MCP
---
## Phase 7: User Story 5 — Agents Reply in Threads via MCP (Priority: P2)
**Goal**: MCP tool descriptions clearly document threading; agents can reliably use reply_to
**Independent Test**: Send threaded message via MCP, verify reply appears in thread, tool descriptions are clear
### Implementation for User Story 5
- [x] T028 [US5] Update send_message tool description to clearly document reply_to usage and threading behavior in internal/mcp/tools_hybrid.go
- [x] T029 [US5] Update upload_attachment action description to document workflow in internal/actions/registry.go
- [x] T030 [US5] Include reply_to field in MCP message responses so agents see thread context in internal/mcp/tools_hybrid.go
**Checkpoint**: MCP tools are self-documenting for threading and attachments
---
## Phase 8: User Story 6 — Admin Backup and Restore Attachments (Priority: P3)
**Goal**: Admin CLI can backup attachments to tar.gz and restore from archive
**Independent Test**: Run backup, verify archive, restore to empty dir, verify files recovered
### Implementation for User Story 6
- [x] T031 [US6] Add `synapbus attachments backup --output <path>` subcommand using archive/tar + compress/gzip in cmd/synapbus/admin.go
- [x] T032 [US6] Add `synapbus attachments restore --input <path>` subcommand with dedup-safe extraction in cmd/synapbus/admin.go
**Checkpoint**: Admin can backup and restore attachment files
---
## Phase 9: Polish & Cross-Cutting Concerns
**Purpose**: Final integration, edge cases, build verification
- [x] T033 Run `go test ./...` to verify all Go tests pass (25 packages, 0 failures)
- [x] T034 Run `make web` to build Svelte SPA and verify no build errors
- [x] T035 Run `make build` to compile final binary (90MB arm64)
- [x] T036 Run integration tests to verify no regressions (9 E2E tests pass)
---
## Completion Summary
All 36 tasks completed. All tests pass. Build successful.
+5 -1
View File
@@ -20,6 +20,10 @@
}
@layer base {
html, body {
overflow-x: hidden;
max-width: 100vw;
}
body {
font-family: 'DM Sans', sans-serif;
background-color: var(--bg-primary);
@@ -128,7 +132,7 @@
background-color: var(--bg-tertiary);
}
.sidebar-item {
@apply flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors cursor-pointer;
@apply flex items-center gap-2 px-3 py-2 md:py-1.5 min-h-[44px] md:min-h-0 rounded text-sm transition-colors cursor-pointer;
color: var(--text-secondary);
}
.sidebar-item:hover {
+3
View File
@@ -4,6 +4,9 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#6366f1" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>SynapBus</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+51 -1
View File
@@ -62,7 +62,7 @@ export const messages = {
return request<{ messages: any[]; total: number }>('GET', `/api/messages${q ? '?' + q : ''}`);
},
get: (id: number) => request<any>('GET', `/api/messages/${id}`),
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number }) =>
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number; attachments?: string[] }) =>
request<any>('POST', '/api/messages', body),
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
search: (q: string, opts?: { limit?: number; channel?: string; agent?: string; after?: string; before?: string }) => {
@@ -200,4 +200,54 @@ export const notificationsApi = {
request<{ status: string }>('POST', '/api/notifications/mark-read', { type, target, last_message_id: lastMessageId })
};
// Analytics
export const analytics = {
timeline: (span = '24h') =>
request<{ span: string; buckets: { time: string; count: number }[]; total: number }>('GET', `/api/analytics/timeline?span=${span}`),
topAgents: (span = '24h', limit = 5) =>
request<{ span: string; agents: { name: string; display_name: string; count: number }[] }>('GET', `/api/analytics/top-agents?span=${span}&limit=${limit}`),
topChannels: (span = '24h', limit = 5) =>
request<{ span: string; channels: { name: string; count: number }[] }>('GET', `/api/analytics/top-channels?span=${span}&limit=${limit}`),
summary: () =>
request<{ total_agents: number; total_channels: number; total_messages: number }>('GET', '/api/analytics/summary')
};
// Version
export const version = {
get: () => request<{ version: string; repo: string }>('GET', '/api/version')
};
// Push notifications
export const push = {
subscribe: (subscription: { endpoint: string; key_p256dh: string; key_auth: string }) =>
request<{ id: number; message: string }>('POST', '/api/push/subscribe', subscription),
unsubscribe: (endpoint: string) =>
request<{ message: string }>('DELETE', '/api/push/subscribe', { endpoint }),
vapidKey: () => request<{ vapid_public_key: string }>('GET', '/api/push/vapid-key')
};
// User profile
export const profile = {
update: (body: { display_name: string }) =>
request<{ message: string; user: { id: number; username: string; display_name: string; role: string } }>('PUT', '/api/auth/profile', body)
};
// Attachments
export const attachments = {
upload: async (file: File): Promise<{hash: string, size: number, mime_type: string, original_filename: string}> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/attachments', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!response.ok) {
const err = await response.json().catch(() => ({error: 'Upload failed'}));
throw new Error(err.error || err.detail || 'Upload failed');
}
return response.json();
}
};
export { ApiError };

Some files were not shown because too many files have changed in this diff Show More