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>
167 lines
3.6 KiB
Go
167 lines
3.6 KiB
Go
package trace
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func newTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open database: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
// Create traces table
|
|
_, err = db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS traces (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agent_name TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
details TEXT NOT NULL DEFAULT '{}',
|
|
error TEXT,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("create traces table: %v", err)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
func TestTracer_Record(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
agent string
|
|
action string
|
|
details any
|
|
}{
|
|
{
|
|
name: "simple trace",
|
|
agent: "test-agent",
|
|
action: "send_message",
|
|
details: map[string]any{
|
|
"to": "other-agent",
|
|
"message": "hello",
|
|
},
|
|
},
|
|
{
|
|
name: "trace with nil details",
|
|
agent: "agent-a",
|
|
action: "read_inbox",
|
|
details: nil,
|
|
},
|
|
{
|
|
name: "trace with string details",
|
|
agent: "agent-b",
|
|
action: "search",
|
|
details: "query string",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db := newTestDB(t)
|
|
tracer := NewTracer(db)
|
|
defer tracer.Close()
|
|
|
|
ctx := context.Background()
|
|
tracer.Record(ctx, tt.agent, tt.action, tt.details)
|
|
|
|
// Give async writer time to process
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Verify trace was written
|
|
var count int
|
|
err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM traces WHERE agent_name = ? AND action = ?",
|
|
tt.agent, tt.action,
|
|
).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("query trace: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("trace count = %d, want 1", count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTracer_RecordError(t *testing.T) {
|
|
db := newTestDB(t)
|
|
tracer := NewTracer(db)
|
|
defer tracer.Close()
|
|
|
|
ctx := context.Background()
|
|
tracer.RecordError(ctx, "test-agent", "failed_action",
|
|
map[string]any{"key": "value"},
|
|
fmt.Errorf("something went wrong"),
|
|
)
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
var errorText sql.NullString
|
|
err := db.QueryRow(
|
|
"SELECT error FROM traces WHERE agent_name = 'test-agent' AND action = 'failed_action'",
|
|
).Scan(&errorText)
|
|
if err != nil {
|
|
t.Fatalf("query trace: %v", err)
|
|
}
|
|
if !errorText.Valid || errorText.String != "something went wrong" {
|
|
t.Errorf("error = %v, want 'something went wrong'", errorText)
|
|
}
|
|
}
|
|
|
|
func TestTraceStore(t *testing.T) {
|
|
db := newTestDB(t)
|
|
tracer := NewTracer(db)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Record some traces
|
|
tracer.Record(ctx, "agent-a", "send_message", map[string]any{"to": "agent-b"})
|
|
tracer.Record(ctx, "agent-a", "read_inbox", map[string]any{"count": 5})
|
|
tracer.Record(ctx, "agent-b", "send_message", map[string]any{"to": "agent-a"})
|
|
|
|
tracer.Close() // flush all entries
|
|
|
|
store := NewSQLiteTraceStore(db)
|
|
|
|
t.Run("get traces by agent", func(t *testing.T) {
|
|
traces, err := store.GetTraces(ctx, "agent-a", 10)
|
|
if err != nil {
|
|
t.Fatalf("GetTraces: %v", err)
|
|
}
|
|
if len(traces) != 2 {
|
|
t.Errorf("got %d traces, want 2", len(traces))
|
|
}
|
|
})
|
|
|
|
t.Run("get traces by action", func(t *testing.T) {
|
|
traces, err := store.GetTracesByAction(ctx, "send_message", 10)
|
|
if err != nil {
|
|
t.Fatalf("GetTracesByAction: %v", err)
|
|
}
|
|
if len(traces) != 2 {
|
|
t.Errorf("got %d traces, want 2", len(traces))
|
|
}
|
|
})
|
|
|
|
t.Run("limit results", func(t *testing.T) {
|
|
traces, err := store.GetTraces(ctx, "agent-a", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetTraces: %v", err)
|
|
}
|
|
if len(traces) != 1 {
|
|
t.Errorf("got %d traces, want 1", len(traces))
|
|
}
|
|
})
|
|
}
|