Files
synapbus/internal/messaging/service.go
T
Algis DumbrisandClaude Opus 4.6 2f55ce87c3 feat: implement core foundation (storage, messaging, agents, MCP server)
Implements the foundational layer that all SynapBus features depend on:

- internal/storage: SQLite connection manager (WAL mode, busy_timeout,
  foreign_keys) and embedded migration runner using modernc.org/sqlite
- internal/messaging: MessagingService with send, read inbox, claim,
  mark done/failed, and FTS5 search. SQLite-backed MessageStore with
  conversation auto-creation and read/unread tracking via inbox_state.
- internal/agents: AgentService with register, authenticate (bcrypt),
  update, deregister, discover by capability. HTTP auth middleware.
- internal/mcp: MCP server using mark3labs/mcp-go with 9 registered
  tools (send_message, read_inbox, claim_messages, mark_done,
  search_messages, register_agent, discover_agents, update_agent,
  deregister_agent). SSE transport, health endpoint, connection manager.
- internal/trace: Async trace recorder with buffered channel for
  recording agent actions to SQLite traces table.
- cmd/synapbus: Updated main.go wiring storage, migrations, services,
  MCP server, chi router, and graceful shutdown.

All code compiles with CGO_ENABLED=0. Full test suite passes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:45:22 +02:00

331 lines
8.5 KiB
Go

package messaging
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
// MessagingService provides business logic for messaging operations.
type MessagingService struct {
store MessageStore
tracer *trace.Tracer
logger *slog.Logger
}
// NewMessagingService creates a new messaging service.
func NewMessagingService(store MessageStore, tracer *trace.Tracer) *MessagingService {
return &MessagingService{
store: store,
tracer: tracer,
logger: slog.Default().With("component", "messaging"),
}
}
// SendMessage creates a message, auto-creating conversations as needed.
func (s *MessagingService) SendMessage(ctx context.Context, from, to, body string, opts SendOptions) (*Message, error) {
// Validate inputs
if strings.TrimSpace(body) == "" {
return nil, fmt.Errorf("message body cannot be empty")
}
if to == "" && opts.ChannelID == nil {
return nil, fmt.Errorf("either to_agent or channel_id must be specified")
}
if to != "" && opts.ChannelID != nil {
return nil, fmt.Errorf("cannot specify both to_agent and channel_id")
}
priority := opts.Priority
if priority == 0 {
priority = 5
}
if priority < 1 || priority > 10 {
return nil, fmt.Errorf("priority must be between 1 and 10")
}
var metadata json.RawMessage
if opts.Metadata != "" {
if !json.Valid([]byte(opts.Metadata)) {
return nil, fmt.Errorf("metadata must be valid JSON")
}
metadata = json.RawMessage(opts.Metadata)
} else {
metadata = json.RawMessage("{}")
}
// For DMs, verify recipient agent exists
if to != "" {
exists, err := s.store.AgentExists(ctx, to)
if err != nil {
return nil, fmt.Errorf("check agent existence: %w", err)
}
if !exists {
return nil, fmt.Errorf("agent not found: %s", to)
}
}
// Find or create conversation
var conv *Conversation
var err error
if opts.ConversationID != nil {
conv, err = s.store.GetConversation(ctx, *opts.ConversationID)
if err != nil {
return nil, fmt.Errorf("get conversation: %w", err)
}
} else if opts.Subject != "" && to != "" {
conv, err = s.store.FindConversation(ctx, opts.Subject, from, to)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("find conversation: %w", err)
}
}
if conv == nil {
conv = &Conversation{
Subject: opts.Subject,
CreatedBy: from,
ChannelID: opts.ChannelID,
}
if err := s.store.InsertConversation(ctx, conv); err != nil {
return nil, fmt.Errorf("create conversation: %w", err)
}
}
msg := &Message{
ConversationID: conv.ID,
FromAgent: from,
ToAgent: to,
ChannelID: opts.ChannelID,
Body: body,
Priority: priority,
Status: StatusPending,
Metadata: metadata,
}
if err := s.store.InsertMessage(ctx, msg); err != nil {
return nil, fmt.Errorf("insert message: %w", err)
}
s.logger.Info("message sent",
"from", from,
"to", to,
"message_id", msg.ID,
"conversation_id", conv.ID,
"priority", priority,
)
// Record trace
if s.tracer != nil {
s.tracer.Record(ctx, from, "send_message", map[string]any{
"message_id": msg.ID,
"conversation_id": conv.ID,
"to": to,
"priority": priority,
})
}
return msg, nil
}
// ReadInbox returns messages for an agent and advances the read position.
func (s *MessagingService) ReadInbox(ctx context.Context, agentName string, opts ReadOptions) ([]*Message, error) {
messages, err := s.store.GetInboxMessages(ctx, agentName, opts)
if err != nil {
return nil, fmt.Errorf("get inbox messages: %w", err)
}
// Advance inbox state for each conversation
conversationMaxID := make(map[int64]int64)
for _, msg := range messages {
if msg.ID > conversationMaxID[msg.ConversationID] {
conversationMaxID[msg.ConversationID] = msg.ID
}
}
for convID, maxMsgID := range conversationMaxID {
if err := s.store.UpdateInboxState(ctx, agentName, convID, maxMsgID); err != nil {
s.logger.Error("failed to update inbox state",
"agent", agentName,
"conversation_id", convID,
"error", err,
)
}
}
s.logger.Info("inbox read",
"agent", agentName,
"message_count", len(messages),
)
if s.tracer != nil {
s.tracer.Record(ctx, agentName, "read_inbox", map[string]any{
"message_count": len(messages),
"include_read": opts.IncludeRead,
})
}
return messages, nil
}
// ClaimMessages atomically claims pending messages for processing.
func (s *MessagingService) ClaimMessages(ctx context.Context, agentName string, limit int) ([]*Message, error) {
if limit <= 0 {
limit = 10
}
messages, err := s.store.ClaimMessages(ctx, agentName, limit)
if err != nil {
return nil, fmt.Errorf("claim messages: %w", err)
}
s.logger.Info("messages claimed",
"agent", agentName,
"count", len(messages),
)
if s.tracer != nil {
ids := make([]int64, len(messages))
for i, m := range messages {
ids[i] = m.ID
}
s.tracer.Record(ctx, agentName, "claim_messages", map[string]any{
"claimed_count": len(messages),
"message_ids": ids,
})
}
return messages, nil
}
// MarkDone marks a message as done. Only the claiming agent can do this.
func (s *MessagingService) MarkDone(ctx context.Context, messageID int64, agentName string) error {
msg, err := s.store.GetMessageByID(ctx, messageID)
if err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("message not found: %d", messageID)
}
return fmt.Errorf("get message: %w", err)
}
if msg.Status != StatusProcessing {
return fmt.Errorf("message %d is not in processing status (current: %s)", messageID, msg.Status)
}
if msg.ClaimedBy != agentName {
return fmt.Errorf("message %d is not claimed by agent %s", messageID, agentName)
}
if err := s.store.UpdateMessageStatus(ctx, messageID, StatusDone, agentName, nil); err != nil {
return fmt.Errorf("mark done: %w", err)
}
s.logger.Info("message marked done",
"agent", agentName,
"message_id", messageID,
)
if s.tracer != nil {
s.tracer.Record(ctx, agentName, "mark_done", map[string]any{
"message_id": messageID,
"status": StatusDone,
})
}
return nil
}
// MarkFailed marks a message as failed with a reason.
func (s *MessagingService) MarkFailed(ctx context.Context, messageID int64, agentName, reason string) error {
msg, err := s.store.GetMessageByID(ctx, messageID)
if err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("message not found: %d", messageID)
}
return fmt.Errorf("get message: %w", err)
}
if msg.Status != StatusProcessing {
return fmt.Errorf("message %d is not in processing status (current: %s)", messageID, msg.Status)
}
if msg.ClaimedBy != agentName {
return fmt.Errorf("message %d is not claimed by agent %s", messageID, agentName)
}
// Merge failure reason into metadata
var existingMeta map[string]any
if err := json.Unmarshal(msg.Metadata, &existingMeta); err != nil {
existingMeta = make(map[string]any)
}
existingMeta["error"] = reason
mergedMeta, err := json.Marshal(existingMeta)
if err != nil {
return fmt.Errorf("marshal metadata: %w", err)
}
if err := s.store.UpdateMessageStatus(ctx, messageID, StatusFailed, agentName, mergedMeta); err != nil {
return fmt.Errorf("mark failed: %w", err)
}
s.logger.Info("message marked failed",
"agent", agentName,
"message_id", messageID,
"reason", reason,
)
if s.tracer != nil {
s.tracer.Record(ctx, agentName, "mark_failed", map[string]any{
"message_id": messageID,
"status": StatusFailed,
"reason": reason,
})
}
return nil
}
// SearchMessages performs full-text search on messages.
func (s *MessagingService) SearchMessages(ctx context.Context, agentName, query string, opts SearchOptions) ([]*Message, error) {
messages, err := s.store.SearchMessages(ctx, agentName, query, opts)
if err != nil {
return nil, fmt.Errorf("search messages: %w", err)
}
s.logger.Info("messages searched",
"agent", agentName,
"query", query,
"result_count", len(messages),
)
if s.tracer != nil {
s.tracer.Record(ctx, agentName, "search_messages", map[string]any{
"query": query,
"result_count": len(messages),
})
}
return messages, nil
}
// 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)
if err != nil {
return nil, nil, fmt.Errorf("get conversation: %w", err)
}
messages, err := s.store.GetConversationMessages(ctx, id)
if err != nil {
return nil, nil, fmt.Errorf("get conversation messages: %w", err)
}
return conv, messages, nil
}