feat: add admin CLI, API keys, threads, and Slack-like UI redesign

Major feature additions across backend and frontend:

Backend:
- Unix domain socket admin server with JSON-RPC protocol
- CLI subcommands: user/agent management, audit, backup, messages, channels
- Managed API keys (sb_ prefix) with permissions, channel limits, expiry
- Thread/reply support in messaging core (reply_to column)
- Context-based trace owner_id propagation for proper audit filtering
- Two-step auth middleware supporting both agent keys and managed API keys

Frontend:
- Complete Slack-like dark theme redesign with custom CSS properties
- Sidebar with Channels, Direct Messages, and Admin sections
- Thread panel (slide-in) for viewing message replies
- API key management page with create form, key display, and
  ready-to-use MCP/Claude Code config snippets with copy-to-clipboard
- All pages restyled: login, dashboard, agents, conversations, settings

E2E Tests:
- Fixed test runner binary path resolution
- Added pyproject.toml for test dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-13 16:10:51 +02:00
co-authored by Claude Opus 4.6
parent 56e3ab3b56
commit b88d52276a
79 changed files with 7471 additions and 549 deletions
+607
View File
@@ -0,0 +1,607 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
)
var adminSocket string
// adminRequest sends a command over the Unix socket and returns the parsed response.
func adminRequest(command string, args interface{}) (map[string]interface{}, error) {
socket := adminSocket
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "./data/synapbus.sock" {
socket = s
}
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, fmt.Errorf("connect to %s: %w (is synapbus serve running?)", socket, err)
}
defer conn.Close()
var argsRaw json.RawMessage
if args != nil {
b, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("marshal args: %w", err)
}
argsRaw = b
}
req := struct {
Command string `json:"command"`
Args json.RawMessage `json:"args,omitempty"`
}{
Command: command,
Args: argsRaw,
}
data, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
data = append(data, '\n')
if _, err := conn.Write(data); err != nil {
return nil, fmt.Errorf("write request: %w", err)
}
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return nil, fmt.Errorf("empty response from server")
}
var resp map[string]interface{}
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if ok, _ := resp["ok"].(bool); !ok {
errMsg, _ := resp["error"].(string)
return nil, fmt.Errorf("error: %s", errMsg)
}
return resp, nil
}
// printTable prints a slice of maps as a table.
func printTable(headers []string, rows []map[string]string) {
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
fmt.Fprintln(w, strings.Repeat("-\t", len(headers)))
for _, row := range rows {
vals := make([]string, len(headers))
for i, h := range headers {
vals[i] = row[h]
}
fmt.Fprintln(w, strings.Join(vals, "\t"))
}
w.Flush()
}
// printJSON pretty-prints a value as JSON.
func printJSON(v interface{}) {
data, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(data))
}
// toMapSlice converts response data ([]interface{}) to []map[string]string.
func toMapSlice(data interface{}) []map[string]string {
arr, ok := data.([]interface{})
if !ok {
return nil
}
var result []map[string]string
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
row := make(map[string]string)
for k, v := range m {
row[k] = fmt.Sprintf("%v", v)
}
result = append(result, row)
}
return result
}
func addAdminCommands(rootCmd *cobra.Command) {
// ----- user commands -----
userCmd := &cobra.Command{
Use: "user",
Short: "Manage users",
}
userListCmd := &cobra.Command{
Use: "list",
Short: "List all users",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("user.list", nil)
if err != nil {
return err
}
rows := toMapSlice(resp["data"])
if len(rows) == 0 {
fmt.Println("No users found.")
return nil
}
printTable([]string{"ID", "USERNAME", "DISPLAY_NAME", "ROLE", "CREATED_AT"}, toTableRows(rows, map[string]string{
"ID": "id", "USERNAME": "username", "DISPLAY_NAME": "display_name", "ROLE": "role", "CREATED_AT": "created_at",
}))
return nil
},
}
var (
userCreateUsername string
userCreatePassword string
userCreateDisplayName string
)
userCreateCmd := &cobra.Command{
Use: "create",
Short: "Create a user",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("user.create", map[string]string{
"username": userCreateUsername,
"password": userCreatePassword,
"display_name": userCreateDisplayName,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
userCreateCmd.Flags().StringVar(&userCreateUsername, "username", "", "Username")
userCreateCmd.Flags().StringVar(&userCreatePassword, "password", "", "Password")
userCreateCmd.Flags().StringVar(&userCreateDisplayName, "display-name", "", "Display name")
userCreateCmd.MarkFlagRequired("username")
userCreateCmd.MarkFlagRequired("password")
var userDeleteUsername string
userDeleteCmd := &cobra.Command{
Use: "delete",
Short: "Delete a user",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("user.delete", map[string]string{
"username": userDeleteUsername,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
userDeleteCmd.Flags().StringVar(&userDeleteUsername, "username", "", "Username to delete")
userDeleteCmd.MarkFlagRequired("username")
var (
userPasswdUsername string
userPasswdPassword string
)
userPasswdCmd := &cobra.Command{
Use: "passwd",
Short: "Change a user's password",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("user.passwd", map[string]string{
"username": userPasswdUsername,
"password": userPasswdPassword,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
userPasswdCmd.Flags().StringVar(&userPasswdUsername, "username", "", "Username")
userPasswdCmd.Flags().StringVar(&userPasswdPassword, "password", "", "New password")
userPasswdCmd.MarkFlagRequired("username")
userPasswdCmd.MarkFlagRequired("password")
userCmd.AddCommand(userListCmd, userCreateCmd, userDeleteCmd, userPasswdCmd)
// ----- agent commands -----
agentCmd := &cobra.Command{
Use: "agent",
Short: "Manage agents",
}
agentListCmd := &cobra.Command{
Use: "list",
Short: "List all agents",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("agent.list", nil)
if err != nil {
return err
}
rows := toMapSlice(resp["data"])
if len(rows) == 0 {
fmt.Println("No agents found.")
return nil
}
printTable([]string{"ID", "NAME", "DISPLAY_NAME", "TYPE", "OWNER_ID", "STATUS", "CREATED_AT"}, toTableRows(rows, map[string]string{
"ID": "id", "NAME": "name", "DISPLAY_NAME": "display_name", "TYPE": "type",
"OWNER_ID": "owner_id", "STATUS": "status", "CREATED_AT": "created_at",
}))
return nil
},
}
var (
agentCreateName string
agentCreateDisplayName string
agentCreateType string
agentCreateOwner int64
)
agentCreateCmd := &cobra.Command{
Use: "create",
Short: "Register a new agent and return its API key",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("agent.create", map[string]interface{}{
"name": agentCreateName,
"display_name": agentCreateDisplayName,
"type": agentCreateType,
"owner_id": agentCreateOwner,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
agentCreateCmd.Flags().StringVar(&agentCreateName, "name", "", "Agent name")
agentCreateCmd.Flags().StringVar(&agentCreateDisplayName, "display-name", "", "Display name")
agentCreateCmd.Flags().StringVar(&agentCreateType, "type", "ai", "Agent type (ai|human)")
agentCreateCmd.Flags().Int64Var(&agentCreateOwner, "owner", 0, "Owner user ID")
agentCreateCmd.MarkFlagRequired("name")
var agentDeleteName string
agentDeleteCmd := &cobra.Command{
Use: "delete",
Short: "Deactivate an agent",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("agent.delete", map[string]string{
"name": agentDeleteName,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
agentDeleteCmd.Flags().StringVar(&agentDeleteName, "name", "", "Agent name")
agentDeleteCmd.MarkFlagRequired("name")
var agentRevokeKeyName string
agentRevokeKeyCmd := &cobra.Command{
Use: "revoke-key",
Short: "Regenerate an agent's API key",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("agent.revoke_key", map[string]string{
"name": agentRevokeKeyName,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
agentRevokeKeyCmd.Flags().StringVar(&agentRevokeKeyName, "name", "", "Agent name")
agentRevokeKeyCmd.MarkFlagRequired("name")
agentCmd.AddCommand(agentListCmd, agentCreateCmd, agentDeleteCmd, agentRevokeKeyCmd)
// ----- audit commands -----
auditCmd := &cobra.Command{
Use: "audit",
Short: "Query audit/trace logs",
}
var (
auditListAgent string
auditListAction string
auditListSince string
auditListLimit int
)
auditListCmd := &cobra.Command{
Use: "list",
Short: "List audit traces",
RunE: func(cmd *cobra.Command, args []string) error {
reqArgs := map[string]interface{}{}
if auditListAgent != "" {
reqArgs["agent_name"] = auditListAgent
}
if auditListAction != "" {
reqArgs["action"] = auditListAction
}
if auditListSince != "" {
reqArgs["since"] = auditListSince
}
if auditListLimit > 0 {
reqArgs["limit"] = auditListLimit
}
resp, err := adminRequest("audit.list", reqArgs)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
auditListCmd.Flags().StringVar(&auditListAgent, "agent", "", "Filter by agent name")
auditListCmd.Flags().StringVar(&auditListAction, "action", "", "Filter by action")
auditListCmd.Flags().StringVar(&auditListSince, "since", "", "Filter since time (RFC3339)")
auditListCmd.Flags().IntVar(&auditListLimit, "limit", 50, "Max results")
auditStatsCmd := &cobra.Command{
Use: "stats",
Short: "Show audit statistics",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("audit.stats", nil)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
var auditExportFormat string
auditExportCmd := &cobra.Command{
Use: "export",
Short: "Export audit traces",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("audit.export", map[string]string{
"format": auditExportFormat,
})
if err != nil {
return err
}
data, _ := resp["data"].(map[string]interface{})
if data != nil && data["format"] == "csv" {
// Print raw CSV.
fmt.Print(data["csv"])
} else {
printJSON(resp["data"])
}
return nil
},
}
auditExportCmd.Flags().StringVar(&auditExportFormat, "format", "json", "Export format (json|csv)")
auditCmd.AddCommand(auditListCmd, auditStatsCmd, auditExportCmd)
// ----- backup command -----
backupCmd := &cobra.Command{
Use: "backup",
Short: "Create a database backup",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("backup", nil)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
// ----- messages commands -----
messagesCmd := &cobra.Command{
Use: "messages",
Short: "Query messages",
}
var (
messagesListAgent string
messagesListStatus string
messagesListLimit int
)
messagesListCmd := &cobra.Command{
Use: "list",
Short: "List messages",
RunE: func(cmd *cobra.Command, args []string) error {
reqArgs := map[string]interface{}{}
if messagesListAgent != "" {
reqArgs["agent"] = messagesListAgent
}
if messagesListStatus != "" {
reqArgs["status"] = messagesListStatus
}
if messagesListLimit > 0 {
reqArgs["limit"] = messagesListLimit
}
resp, err := adminRequest("messages.list", reqArgs)
if err != nil {
return err
}
rows := toMapSlice(resp["data"])
if len(rows) == 0 {
fmt.Println("No messages found.")
return nil
}
printTable([]string{"ID", "FROM", "TO", "STATUS", "PRIORITY", "BODY", "CREATED_AT"}, toTableRows(rows, map[string]string{
"ID": "id", "FROM": "from_agent", "TO": "to_agent",
"STATUS": "status", "PRIORITY": "priority", "BODY": "body", "CREATED_AT": "created_at",
}))
return nil
},
}
messagesListCmd.Flags().StringVar(&messagesListAgent, "agent", "", "Filter by agent name")
messagesListCmd.Flags().StringVar(&messagesListStatus, "status", "", "Filter by status")
messagesListCmd.Flags().IntVar(&messagesListLimit, "limit", 50, "Max results")
var (
messagesSearchQuery string
messagesSearchLimit int
)
messagesSearchCmd := &cobra.Command{
Use: "search",
Short: "Search messages",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("messages.search", map[string]interface{}{
"query": messagesSearchQuery,
"limit": messagesSearchLimit,
})
if err != nil {
return err
}
rows := toMapSlice(resp["data"])
if len(rows) == 0 {
fmt.Println("No messages found.")
return nil
}
printTable([]string{"ID", "FROM", "TO", "STATUS", "BODY", "CREATED_AT"}, toTableRows(rows, map[string]string{
"ID": "id", "FROM": "from_agent", "TO": "to_agent",
"STATUS": "status", "BODY": "body", "CREATED_AT": "created_at",
}))
return nil
},
}
messagesSearchCmd.Flags().StringVar(&messagesSearchQuery, "query", "", "Search query")
messagesSearchCmd.Flags().IntVar(&messagesSearchLimit, "limit", 20, "Max results")
messagesSearchCmd.MarkFlagRequired("query")
messagesCmd.AddCommand(messagesListCmd, messagesSearchCmd)
// ----- channels commands -----
channelsCmd := &cobra.Command{
Use: "channels",
Short: "Manage channels",
}
channelsListCmd := &cobra.Command{
Use: "list",
Short: "List all channels",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("channels.list", nil)
if err != nil {
return err
}
rows := toMapSlice(resp["data"])
if len(rows) == 0 {
fmt.Println("No channels found.")
return nil
}
printTable([]string{"ID", "NAME", "TYPE", "PRIVATE", "MEMBERS", "CREATED_BY", "CREATED_AT"}, toTableRows(rows, map[string]string{
"ID": "id", "NAME": "name", "TYPE": "type", "PRIVATE": "is_private",
"MEMBERS": "member_count", "CREATED_BY": "created_by", "CREATED_AT": "created_at",
}))
return nil
},
}
var channelsShowName string
channelsShowCmd := &cobra.Command{
Use: "show",
Short: "Show channel details and members",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("channels.show", map[string]string{
"name": channelsShowName,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
channelsShowCmd.Flags().StringVar(&channelsShowName, "name", "", "Channel name")
channelsShowCmd.MarkFlagRequired("name")
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd)
// ----- conversations commands -----
conversationsCmd := &cobra.Command{
Use: "conversations",
Short: "Query conversations",
}
var conversationsListLimit int
conversationsListCmd := &cobra.Command{
Use: "list",
Short: "List conversations",
RunE: func(cmd *cobra.Command, args []string) error {
reqArgs := map[string]interface{}{}
if conversationsListLimit > 0 {
reqArgs["limit"] = conversationsListLimit
}
resp, err := adminRequest("conversations.list", reqArgs)
if err != nil {
return err
}
rows := toMapSlice(resp["data"])
if len(rows) == 0 {
fmt.Println("No conversations found.")
return nil
}
printTable([]string{"ID", "SUBJECT", "CREATED_BY", "MESSAGES", "CREATED_AT"}, toTableRows(rows, map[string]string{
"ID": "id", "SUBJECT": "subject", "CREATED_BY": "created_by",
"MESSAGES": "message_count", "CREATED_AT": "created_at",
}))
return nil
},
}
conversationsListCmd.Flags().IntVar(&conversationsListLimit, "limit", 50, "Max results")
var conversationsShowID int64
conversationsShowCmd := &cobra.Command{
Use: "show",
Short: "Show conversation messages",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("conversations.show", map[string]interface{}{
"id": conversationsShowID,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
conversationsShowCmd.Flags().Int64Var(&conversationsShowID, "id", 0, "Conversation ID")
conversationsShowCmd.MarkFlagRequired("id")
conversationsCmd.AddCommand(conversationsListCmd, conversationsShowCmd)
// ----- add persistent flag and commands to root -----
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "./data/synapbus.sock", "Path to admin Unix socket")
rootCmd.AddCommand(userCmd, agentCmd, auditCmd, backupCmd, messagesCmd, channelsCmd, conversationsCmd)
}
// toTableRows remaps []map[string]string using a header->key mapping.
func toTableRows(data []map[string]string, headerMap map[string]string) []map[string]string {
var rows []map[string]string
for _, d := range data {
row := make(map[string]string)
for header, key := range headerMap {
val := d[key]
// Truncate body field for table display.
if key == "body" && len(val) > 60 {
val = val[:57] + "..."
}
row[header] = val
}
rows = append(rows, row)
}
return rows
}
+47 -3
View File
@@ -18,8 +18,10 @@ import (
"github.com/go-chi/chi/v5"
"github.com/spf13/cobra"
"github.com/smart-mcp-proxy/synapbus/internal/admin"
"github.com/smart-mcp-proxy/synapbus/internal/agents"
"github.com/smart-mcp-proxy/synapbus/internal/api"
"github.com/smart-mcp-proxy/synapbus/internal/apikeys"
"github.com/smart-mcp-proxy/synapbus/internal/attachments"
"github.com/smart-mcp-proxy/synapbus/internal/auth"
"github.com/smart-mcp-proxy/synapbus/internal/channels"
@@ -33,11 +35,13 @@ import (
)
var (
host string
port int
dataDir string
logLevel string
metricsEnabled bool
traceRetention string
adminSocketPath string
)
func main() {
@@ -53,14 +57,19 @@ func main() {
RunE: runServe,
}
serveCmd.Flags().StringVar(&host, "host", "0.0.0.0", "HTTP server bind address")
serveCmd.Flags().IntVar(&port, "port", 8080, "HTTP server port")
serveCmd.Flags().StringVar(&dataDir, "data", "./data", "Data directory for storage")
serveCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level: debug, info, warn, error")
serveCmd.Flags().BoolVar(&metricsEnabled, "metrics", false, "Enable Prometheus metrics endpoint at /metrics")
serveCmd.Flags().StringVar(&traceRetention, "trace-retention", "0", "Trace retention period (e.g. 30d, 90d, 0 for unlimited)")
serveCmd.Flags().StringVar(&adminSocketPath, "admin-socket", "", "Admin Unix socket path (default: {data}/synapbus.sock)")
rootCmd.AddCommand(serveCmd)
// Add admin CLI subcommands.
addAdminCommands(rootCmd)
if err := rootCmd.Execute(); err != nil {
slog.Error("command failed", "error", err)
os.Exit(1)
@@ -103,6 +112,9 @@ func parseRetentionDuration(s string) time.Duration {
func runServe(cmd *cobra.Command, args []string) error {
// Check for environment variable overrides
if h := os.Getenv("SYNAPBUS_HOST"); h != "" {
host = h
}
if p := os.Getenv("SYNAPBUS_PORT"); p != "" {
fmt.Sscanf(p, "%d", &port)
}
@@ -118,6 +130,12 @@ func runServe(cmd *cobra.Command, args []string) error {
if tr := os.Getenv("SYNAPBUS_TRACE_RETENTION"); tr != "" {
traceRetention = tr
}
if as := os.Getenv("SYNAPBUS_ADMIN_SOCKET"); as != "" {
adminSocketPath = as
}
if adminSocketPath == "" {
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
}
// Configure slog with JSON handler
level := parseLogLevel(logLevel)
@@ -129,11 +147,13 @@ func runServe(cmd *cobra.Command, args []string) error {
defer cancel()
slog.Info("starting SynapBus",
"host", host,
"port", port,
"data_dir", dataDir,
"log_level", logLevel,
"metrics_enabled", metricsEnabled,
"trace_retention", traceRetention,
"admin_socket", adminSocketPath,
)
// Initialize SQLite database
@@ -305,6 +325,10 @@ func runServe(cmd *cobra.Command, args []string) error {
slog.Info("semantic search not configured, using full-text search only")
}
// Create API key service
apiKeyStore := apikeys.NewSQLiteStore(db.DB)
apiKeyService := apikeys.NewService(apiKeyStore)
// Create MCP server (with swarm + attachment + search tools)
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService)
startTime := time.Now()
@@ -337,9 +361,9 @@ func runServe(cmd *cobra.Command, args []string) error {
r.Put("/auth/password", authHandlers.HandleChangePassword)
})
// MCP Streamable HTTP endpoint (with optional agent auth)
// MCP Streamable HTTP endpoint (with optional agent auth + API keys)
r.Group(func(r chi.Router) {
r.Use(agents.OptionalAuthMiddleware(agentService))
r.Use(agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService))
r.Mount("/mcp", mcpSrv.Handler())
})
@@ -355,6 +379,7 @@ func runServe(cmd *cobra.Command, args []string) error {
MsgService: msgService,
AgentService: agentService,
ChannelService: channelService,
APIKeyService: apiKeyService,
SSEHub: sseHub,
SessionMiddleware: sessionMiddleware,
})
@@ -363,8 +388,24 @@ func runServe(cmd *cobra.Command, args []string) error {
// Serve embedded Web UI SPA (catch-all for non-API routes)
r.NotFound(web.NewSPAHandler().ServeHTTP)
// Start admin socket server
adminSvcs := &admin.Services{
Users: userStore,
Sessions: sessionStore,
Agents: agentService,
Messages: msgService,
Channels: channelService,
Traces: traceStore,
DataDir: dataDir,
}
adminServer := admin.NewServer(adminSocketPath, db.DB, adminSvcs, logger)
if err := adminServer.Start(); err != nil {
return fmt.Errorf("start admin socket: %w", err)
}
slog.Info("admin socket listening", "path", adminServer.SocketPath())
// Start HTTP server
addr := fmt.Sprintf(":%d", port)
addr := fmt.Sprintf("%s:%d", host, port)
srv := &http.Server{
Addr: addr,
Handler: r,
@@ -393,6 +434,9 @@ func runServe(cmd *cobra.Command, args []string) error {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
// Stop admin socket
adminServer.Stop()
// Stop expiry worker
expiryWorker.Stop()
+52
View File
@@ -0,0 +1,52 @@
// Package admin provides a Unix domain socket server for local administration.
package admin
import (
"database/sql"
"log/slog"
"net"
"github.com/smart-mcp-proxy/synapbus/internal/agents"
"github.com/smart-mcp-proxy/synapbus/internal/auth"
"github.com/smart-mcp-proxy/synapbus/internal/channels"
"github.com/smart-mcp-proxy/synapbus/internal/messaging"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
// Services holds references to all services the admin socket can control.
type Services struct {
Users *auth.SQLiteUserStore
Sessions auth.SessionStore
Agents *agents.AgentService
Messages *messaging.MessagingService
Channels *channels.Service
Traces trace.TraceStore
DataDir string
}
// AdminServer is a Unix domain socket server for local administration.
type AdminServer struct {
listener net.Listener
db *sql.DB
services *Services
logger *slog.Logger
socketPath string
done chan struct{}
}
// NewServer creates a new admin server bound to a Unix socket at {dataDir}/synapbus.sock.
// If socketPath is non-empty it overrides the default.
func NewServer(socketPath string, db *sql.DB, services *Services, logger *slog.Logger) *AdminServer {
return &AdminServer{
db: db,
services: services,
logger: logger.With("component", "admin"),
socketPath: socketPath,
done: make(chan struct{}),
}
}
// SocketPath returns the path to the Unix socket.
func (s *AdminServer) SocketPath() string {
return s.socketPath
}
+945
View File
@@ -0,0 +1,945 @@
package admin
import (
"bufio"
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/smart-mcp-proxy/synapbus/internal/messaging"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
// Request is the JSON-RPC style request sent over the admin socket.
type Request struct {
Command string `json:"command"`
Args json.RawMessage `json:"args,omitempty"`
}
// Response is the JSON response returned over the admin socket.
type Response struct {
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// Start begins listening on the Unix socket. It accepts connections in a loop
// until Stop is called.
func (s *AdminServer) Start() error {
// Remove stale socket file if it exists.
os.Remove(s.socketPath)
ln, err := net.Listen("unix", s.socketPath)
if err != nil {
return fmt.Errorf("listen on %s: %w", s.socketPath, err)
}
s.listener = ln
// Make the socket accessible by the owner only.
os.Chmod(s.socketPath, 0o600)
s.logger.Info("admin socket listening", "path", s.socketPath)
go s.acceptLoop()
return nil
}
// Stop closes the listener and removes the socket file.
func (s *AdminServer) Stop() {
if s.listener != nil {
s.listener.Close()
}
os.Remove(s.socketPath)
close(s.done)
s.logger.Info("admin socket stopped")
}
func (s *AdminServer) acceptLoop() {
for {
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.done:
return
default:
}
if !isClosedError(err) {
s.logger.Error("accept error", "error", err)
}
return
}
go s.handleConn(conn)
}
}
func isClosedError(err error) bool {
return strings.Contains(err.Error(), "use of closed network connection")
}
func (s *AdminServer) handleConn(conn net.Conn) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
// Allow up to 10 MB lines for large exports.
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req Request
if err := json.Unmarshal(line, &req); err != nil {
s.writeResponse(conn, Response{OK: false, Error: "invalid JSON: " + err.Error()})
continue
}
resp := s.dispatch(req)
s.writeResponse(conn, resp)
}
}
func (s *AdminServer) writeResponse(w io.Writer, resp Response) {
data, _ := json.Marshal(resp)
data = append(data, '\n')
w.Write(data)
}
// dispatch routes a request to the appropriate handler.
func (s *AdminServer) dispatch(req Request) Response {
ctx := context.Background()
switch req.Command {
// --- user commands ---
case "user.list":
return s.handleUserList(ctx)
case "user.create":
return s.handleUserCreate(ctx, req.Args)
case "user.delete":
return s.handleUserDelete(ctx, req.Args)
case "user.passwd":
return s.handleUserPasswd(ctx, req.Args)
// --- agent commands ---
case "agent.list":
return s.handleAgentList(ctx)
case "agent.create":
return s.handleAgentCreate(ctx, req.Args)
case "agent.delete":
return s.handleAgentDelete(ctx, req.Args)
case "agent.revoke_key":
return s.handleAgentRevokeKey(ctx, req.Args)
// --- audit commands ---
case "audit.list":
return s.handleAuditList(ctx, req.Args)
case "audit.stats":
return s.handleAuditStats(ctx)
case "audit.export":
return s.handleAuditExport(ctx, req.Args)
// --- backup ---
case "backup":
return s.handleBackup(ctx)
// --- messages ---
case "messages.list":
return s.handleMessagesList(ctx, req.Args)
case "messages.search":
return s.handleMessagesSearch(ctx, req.Args)
// --- channels ---
case "channels.list":
return s.handleChannelsList(ctx)
case "channels.show":
return s.handleChannelsShow(ctx, req.Args)
// --- conversations ---
case "conversations.list":
return s.handleConversationsList(ctx, req.Args)
case "conversations.show":
return s.handleConversationsShow(ctx, req.Args)
default:
return Response{OK: false, Error: fmt.Sprintf("unknown command: %s", req.Command)}
}
}
// ---------- user handlers ----------
func (s *AdminServer) handleUserList(ctx context.Context) Response {
users, err := s.services.Users.ListUsers(ctx)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
type userRow struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
}
rows := make([]userRow, len(users))
for i, u := range users {
rows[i] = userRow{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: u.Role,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
return Response{OK: true, Data: rows}
}
func (s *AdminServer) handleUserCreate(ctx context.Context, args json.RawMessage) Response {
var p struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Username == "" || p.Password == "" {
return Response{OK: false, Error: "username and password are required"}
}
user, err := s.services.Users.CreateUser(ctx, p.Username, p.Password, p.DisplayName)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"id": user.ID,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
}}
}
func (s *AdminServer) handleUserDelete(ctx context.Context, args json.RawMessage) Response {
var p struct {
Username string `json:"username"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Username == "" {
return Response{OK: false, Error: "username is required"}
}
user, err := s.services.Users.GetUserByUsername(ctx, p.Username)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
// Delete all sessions first, then delete the user row.
if err := s.services.Sessions.DeleteSessionsByUser(ctx, user.ID); err != nil {
return Response{OK: false, Error: "delete sessions: " + err.Error()}
}
_, err = s.db.ExecContext(ctx, "DELETE FROM users WHERE id = ?", user.ID)
if err != nil {
return Response{OK: false, Error: "delete user: " + err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"deleted": p.Username,
}}
}
func (s *AdminServer) handleUserPasswd(ctx context.Context, args json.RawMessage) Response {
var p struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Username == "" || p.Password == "" {
return Response{OK: false, Error: "username and password are required"}
}
user, err := s.services.Users.GetUserByUsername(ctx, p.Username)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
if err := s.services.Users.UpdatePassword(ctx, user.ID, p.Password); err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"updated": p.Username,
}}
}
// ---------- agent handlers ----------
func (s *AdminServer) handleAgentList(ctx context.Context) Response {
// Use the store directly via DiscoverAgents with empty query to get all active agents.
agentList, err := s.services.Agents.DiscoverAgents(ctx, "")
if err != nil {
return Response{OK: false, Error: err.Error()}
}
type agentRow struct {
ID int64 `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
OwnerID int64 `json:"owner_id"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
rows := make([]agentRow, len(agentList))
for i, a := range agentList {
rows[i] = agentRow{
ID: a.ID,
Name: a.Name,
DisplayName: a.DisplayName,
Type: a.Type,
OwnerID: a.OwnerID,
Status: a.Status,
CreatedAt: a.CreatedAt.Format(time.RFC3339),
}
}
return Response{OK: true, Data: rows}
}
func (s *AdminServer) handleAgentCreate(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
Capabilities json.RawMessage `json:"capabilities"`
OwnerID int64 `json:"owner_id"`
}
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"}
}
agent, apiKey, err := s.services.Agents.Register(ctx, p.Name, p.DisplayName, p.Type, p.Capabilities, p.OwnerID)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"id": agent.ID,
"name": agent.Name,
"api_key": apiKey,
"status": agent.Status,
}}
}
func (s *AdminServer) handleAgentDelete(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
}
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"}
}
// Admin bypass: deactivate directly via the store.
_, err := s.db.ExecContext(ctx,
`UPDATE agents SET status = 'inactive', updated_at = CURRENT_TIMESTAMP WHERE name = ? AND status = 'active'`,
p.Name,
)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"deactivated": p.Name,
}}
}
func (s *AdminServer) handleAgentRevokeKey(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
}
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"}
}
// Look up the agent to get its owner_id so RevokeKey works.
agent, err := s.services.Agents.GetAgent(ctx, p.Name)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
_, apiKey, err := s.services.Agents.RevokeKey(ctx, p.Name, agent.OwnerID)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"name": p.Name,
"new_api_key": apiKey,
}}
}
// ---------- audit handlers ----------
func (s *AdminServer) handleAuditList(ctx context.Context, args json.RawMessage) Response {
var p struct {
AgentName string `json:"agent_name"`
Action string `json:"action"`
Since string `json:"since"`
Limit int `json:"limit"`
}
if args != nil {
json.Unmarshal(args, &p)
}
filter := trace.TraceFilter{
AgentName: p.AgentName,
Action: p.Action,
PageSize: p.Limit,
Page: 1,
}
if p.Since != "" {
t, err := time.Parse(time.RFC3339, p.Since)
if err == nil {
filter.Since = &t
}
}
if filter.PageSize <= 0 {
filter.PageSize = 50
}
traces, total, err := s.services.Traces.Query(ctx, filter)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"traces": traces,
"total": total,
}}
}
func (s *AdminServer) handleAuditStats(ctx context.Context) Response {
// Count by action for all owners (empty owner_id means all).
counts, err := s.services.Traces.CountByAction(ctx, "")
if err != nil {
// Fallback: try with a simple count query.
var total int
s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM traces").Scan(&total)
return Response{OK: true, Data: map[string]interface{}{
"total_traces": total,
}}
}
var totalTraces int64
for _, c := range counts {
totalTraces += c
}
return Response{OK: true, Data: map[string]interface{}{
"total_traces": totalTraces,
"counts_by_action": counts,
}}
}
func (s *AdminServer) handleAuditExport(ctx context.Context, args json.RawMessage) Response {
var p struct {
Format string `json:"format"`
}
if args != nil {
json.Unmarshal(args, &p)
}
if p.Format == "" {
p.Format = "json"
}
filter := trace.TraceFilter{
PageSize: 10000,
Page: 1,
}
traces, _, err := s.services.Traces.Query(ctx, filter)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
if p.Format == "csv" {
var buf strings.Builder
w := csv.NewWriter(&buf)
w.Write([]string{"id", "agent_name", "action", "details", "error", "timestamp"})
for _, t := range traces {
w.Write([]string{
fmt.Sprintf("%d", t.ID),
t.AgentName,
t.Action,
string(t.Details),
t.Error,
t.Timestamp.Format(time.RFC3339),
})
}
w.Flush()
return Response{OK: true, Data: map[string]interface{}{
"format": "csv",
"csv": buf.String(),
}}
}
return Response{OK: true, Data: map[string]interface{}{
"format": "json",
"traces": traces,
}}
}
// ---------- backup handler ----------
func (s *AdminServer) handleBackup(ctx context.Context) Response {
// Checkpoint WAL first.
if _, err := s.db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
return Response{OK: false, Error: "wal checkpoint: " + err.Error()}
}
backupDir := filepath.Join(s.services.DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return Response{OK: false, Error: "create backup dir: " + err.Error()}
}
ts := time.Now().Format("20060102-150405")
backupPath := filepath.Join(backupDir, fmt.Sprintf("synapbus-%s.db", ts))
srcPath := filepath.Join(s.services.DataDir, "synapbus.db")
src, err := os.Open(srcPath)
if err != nil {
return Response{OK: false, Error: "open source db: " + err.Error()}
}
defer src.Close()
dst, err := os.Create(backupPath)
if err != nil {
return Response{OK: false, Error: "create backup file: " + err.Error()}
}
defer dst.Close()
n, err := io.Copy(dst, src)
if err != nil {
return Response{OK: false, Error: "copy db: " + err.Error()}
}
s.logger.Info("backup created", "path", backupPath, "bytes", n)
return Response{OK: true, Data: map[string]interface{}{
"path": backupPath,
"bytes": n,
}}
}
// ---------- messages handlers ----------
func (s *AdminServer) handleMessagesList(ctx context.Context, args json.RawMessage) Response {
var p struct {
Agent string `json:"agent"`
Status string `json:"status"`
Limit int `json:"limit"`
}
if args != nil {
json.Unmarshal(args, &p)
}
if p.Limit <= 0 {
p.Limit = 50
}
// Query messages directly from DB for admin access (no agent scoping).
var conditions []string
var queryArgs []any
if p.Agent != "" {
conditions = append(conditions, "(from_agent = ? OR to_agent = ?)")
queryArgs = append(queryArgs, p.Agent, p.Agent)
}
if p.Status != "" {
conditions = append(conditions, "status = ?")
queryArgs = append(queryArgs, p.Status)
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
query := fmt.Sprintf(
`SELECT id, conversation_id, from_agent, COALESCE(to_agent, ''), COALESCE(channel_id, 0),
body, priority, status, metadata, COALESCE(claimed_by, ''), claimed_at,
created_at, updated_at
FROM messages%s ORDER BY created_at DESC LIMIT ?`, where)
queryArgs = append(queryArgs, p.Limit)
rows, err := s.db.QueryContext(ctx, query, queryArgs...)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
defer rows.Close()
type msgRow struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversation_id"`
FromAgent string `json:"from_agent"`
ToAgent string `json:"to_agent,omitempty"`
ChannelID int64 `json:"channel_id,omitempty"`
Body string `json:"body"`
Priority int `json:"priority"`
Status string `json:"status"`
ClaimedBy string `json:"claimed_by,omitempty"`
CreatedAt string `json:"created_at"`
}
var result []msgRow
for rows.Next() {
var m msgRow
var metadata, claimedBy string
var channelID int64
var claimedAt *time.Time
if err := rows.Scan(&m.ID, &m.ConversationID, &m.FromAgent, &m.ToAgent, &channelID,
&m.Body, &m.Priority, &m.Status, &metadata, &claimedBy, &claimedAt,
&m.CreatedAt, // will be scanned as string below
new(string), // updated_at (unused)
); err != nil {
return Response{OK: false, Error: "scan: " + err.Error()}
}
// Re-scan with proper types
result = append(result, m)
}
// The above scan approach is fragile with time types. Use a simpler direct query.
return s.handleMessagesListDirect(ctx, p.Agent, p.Status, p.Limit)
}
func (s *AdminServer) handleMessagesListDirect(ctx context.Context, agent, status string, limit int) Response {
var conditions []string
var queryArgs []any
if agent != "" {
conditions = append(conditions, "(from_agent = ? OR to_agent = ?)")
queryArgs = append(queryArgs, agent, agent)
}
if status != "" {
conditions = append(conditions, "status = ?")
queryArgs = append(queryArgs, status)
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
query := fmt.Sprintf(
`SELECT id, conversation_id, from_agent, COALESCE(to_agent, ''), body, priority, status, created_at
FROM messages%s ORDER BY created_at DESC LIMIT ?`, where)
queryArgs = append(queryArgs, limit)
rows, err := s.db.QueryContext(ctx, query, queryArgs...)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
defer rows.Close()
type msgRow struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversation_id"`
FromAgent string `json:"from_agent"`
ToAgent string `json:"to_agent,omitempty"`
Body string `json:"body"`
Priority int `json:"priority"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
var result []msgRow
for rows.Next() {
var m msgRow
var createdAt time.Time
if err := rows.Scan(&m.ID, &m.ConversationID, &m.FromAgent, &m.ToAgent,
&m.Body, &m.Priority, &m.Status, &createdAt); err != nil {
return Response{OK: false, Error: "scan: " + err.Error()}
}
m.CreatedAt = createdAt.Format(time.RFC3339)
result = append(result, m)
}
if result == nil {
result = []msgRow{}
}
return Response{OK: true, Data: result}
}
func (s *AdminServer) handleMessagesSearch(ctx context.Context, args json.RawMessage) Response {
var p struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Query == "" {
return Response{OK: false, Error: "query is required"}
}
if p.Limit <= 0 {
p.Limit = 20
}
// Admin search: use FTS on all messages (no agent scoping).
rows, err := s.db.QueryContext(ctx,
`SELECT m.id, m.conversation_id, m.from_agent, COALESCE(m.to_agent, ''),
m.body, m.priority, m.status, m.created_at
FROM messages m
JOIN messages_fts ON messages_fts.rowid = m.id
WHERE messages_fts MATCH ?
ORDER BY rank
LIMIT ?`, p.Query, p.Limit)
if err != nil {
// FTS may not be available; fall back to LIKE search.
rows, err = s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, COALESCE(to_agent, ''),
body, priority, status, created_at
FROM messages
WHERE body LIKE ?
ORDER BY created_at DESC
LIMIT ?`, "%"+p.Query+"%", p.Limit)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
}
defer rows.Close()
type msgRow struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversation_id"`
FromAgent string `json:"from_agent"`
ToAgent string `json:"to_agent,omitempty"`
Body string `json:"body"`
Priority int `json:"priority"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
var result []msgRow
for rows.Next() {
var m msgRow
var createdAt time.Time
if err := rows.Scan(&m.ID, &m.ConversationID, &m.FromAgent, &m.ToAgent,
&m.Body, &m.Priority, &m.Status, &createdAt); err != nil {
return Response{OK: false, Error: "scan: " + err.Error()}
}
m.CreatedAt = createdAt.Format(time.RFC3339)
result = append(result, m)
}
if result == nil {
result = []msgRow{}
}
return Response{OK: true, Data: result}
}
// ---------- channels handlers ----------
func (s *AdminServer) handleChannelsList(ctx context.Context) Response {
// Admin: list all channels (not scoped to an agent).
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.created_by, c.created_at,
(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id) as member_count
FROM channels c ORDER BY c.name`)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
defer rows.Close()
type chRow struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Topic string `json:"topic"`
Type string `json:"type"`
IsPrivate bool `json:"is_private"`
CreatedBy string `json:"created_by"`
MemberCount int `json:"member_count"`
CreatedAt string `json:"created_at"`
}
var result []chRow
for rows.Next() {
var ch chRow
var isPrivate int
var createdAt time.Time
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type,
&isPrivate, &ch.CreatedBy, &createdAt, &ch.MemberCount); err != nil {
return Response{OK: false, Error: "scan: " + err.Error()}
}
ch.IsPrivate = isPrivate != 0
ch.CreatedAt = createdAt.Format(time.RFC3339)
result = append(result, ch)
}
if result == nil {
result = []chRow{}
}
return Response{OK: true, Data: result}
}
func (s *AdminServer) handleChannelsShow(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Name == "" {
return Response{OK: false, Error: "name is required"}
}
ch, err := s.services.Channels.GetChannelByName(ctx, p.Name)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
members, err := s.services.Channels.GetMembers(ctx, ch.ID)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
type memberRow struct {
AgentName string `json:"agent_name"`
Role string `json:"role"`
JoinedAt string `json:"joined_at"`
}
memberRows := make([]memberRow, len(members))
for i, m := range members {
memberRows[i] = memberRow{
AgentName: m.AgentName,
Role: m.Role,
JoinedAt: m.JoinedAt.Format(time.RFC3339),
}
}
return Response{OK: true, Data: map[string]interface{}{
"channel": ch,
"members": memberRows,
}}
}
// ---------- conversations handlers ----------
func (s *AdminServer) handleConversationsList(ctx context.Context, args json.RawMessage) Response {
var p struct {
Limit int `json:"limit"`
}
if args != nil {
json.Unmarshal(args, &p)
}
if p.Limit <= 0 {
p.Limit = 50
}
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.subject, c.created_by, c.created_at,
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) as msg_count
FROM conversations c
ORDER BY c.updated_at DESC
LIMIT ?`, p.Limit)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
defer rows.Close()
type convRow struct {
ID int64 `json:"id"`
Subject string `json:"subject"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
MsgCount int `json:"message_count"`
}
var result []convRow
for rows.Next() {
var c convRow
var createdAt time.Time
if err := rows.Scan(&c.ID, &c.Subject, &c.CreatedBy, &createdAt, &c.MsgCount); err != nil {
return Response{OK: false, Error: "scan: " + err.Error()}
}
c.CreatedAt = createdAt.Format(time.RFC3339)
result = append(result, c)
}
if result == nil {
result = []convRow{}
}
return Response{OK: true, Data: result}
}
func (s *AdminServer) handleConversationsShow(ctx context.Context, args json.RawMessage) Response {
var p struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.ID <= 0 {
return Response{OK: false, Error: "id is required"}
}
conv, messages, err := s.services.Messages.GetConversation(ctx, p.ID)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
// Strip metadata from messages to keep output clean; convert to simple form.
type simplMsg struct {
ID int64 `json:"id"`
From string `json:"from"`
To string `json:"to,omitempty"`
Body string `json:"body"`
Status string `json:"status"`
Priority int `json:"priority"`
CreatedAt string `json:"created_at"`
}
msgs := make([]simplMsg, len(messages))
for i, m := range messages {
msgs[i] = simplMsg{
ID: m.ID,
From: m.FromAgent,
To: m.ToAgent,
Body: m.Body,
Status: m.Status,
Priority: m.Priority,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
}
}
return Response{OK: true, Data: map[string]interface{}{
"conversation": conv,
"messages": msgs,
}}
}
// Ensure the messaging import is used.
var _ = messaging.StatusPending
+114 -27
View File
@@ -2,14 +2,21 @@ package agents
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/smart-mcp-proxy/synapbus/internal/apikeys"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
type contextKey string
const agentContextKey contextKey = "agent"
const (
agentContextKey contextKey = "agent"
apiKeyContextKey contextKey = "api_key"
)
// AgentFromContext extracts the authenticated agent from the context.
func AgentFromContext(ctx context.Context) (*Agent, bool) {
@@ -22,11 +29,28 @@ func ContextWithAgent(ctx context.Context, agent *Agent) context.Context {
return context.WithValue(ctx, agentContextKey, agent)
}
// APIKeyFromContext extracts the API key metadata from the context.
func APIKeyFromContext(ctx context.Context) (*apikeys.APIKey, bool) {
key, ok := ctx.Value(apiKeyContextKey).(*apikeys.APIKey)
return key, ok
}
// ContextWithAPIKey returns a new context with the API key set.
func ContextWithAPIKey(ctx context.Context, key *apikeys.APIKey) context.Context {
return context.WithValue(ctx, apiKeyContextKey, key)
}
// OptionalAuthMiddleware creates HTTP middleware that authenticates requests
// via API key if an Authorization header is present, but allows
// unauthenticated requests to pass through. This is used for endpoints
// like MCP where some tools (register_agent) work without auth.
// unauthenticated requests to pass through.
func OptionalAuthMiddleware(service *AgentService) func(http.Handler) http.Handler {
return OptionalAuthMiddlewareWithAPIKeys(service, nil)
}
// OptionalAuthMiddlewareWithAPIKeys creates HTTP middleware that authenticates
// via agent API keys or the new managed API keys. Unauthenticated requests
// pass through for endpoints like MCP where some tools work without auth.
func OptionalAuthMiddlewareWithAPIKeys(service *AgentService, keyService *apikeys.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
@@ -41,30 +65,65 @@ func OptionalAuthMiddleware(service *AgentService) func(http.Handler) http.Handl
return
}
apiKey := parts[1]
agent, err := service.Authenticate(r.Context(), apiKey)
if err != nil {
slog.Warn("MCP authentication failed",
bearerToken := parts[1]
// 1. Try existing agent API key auth
agent, err := service.Authenticate(r.Context(), bearerToken)
if err == nil {
slog.Debug("agent authenticated (MCP)",
"agent", agent.Name,
"remote_addr", r.RemoteAddr,
"error", err,
)
http.Error(w, `{"error":"unauthorized","message":"Invalid API key"}`, http.StatusUnauthorized)
ctx := ContextWithAgent(r.Context(), agent)
ctx = trace.ContextWithOwnerID(ctx, fmt.Sprintf("%d", agent.OwnerID))
next.ServeHTTP(w, r.WithContext(ctx))
return
}
slog.Debug("agent authenticated (MCP)",
"agent", agent.Name,
"remote_addr", r.RemoteAddr,
)
// 2. Try new managed API key auth (sb_ prefixed keys)
if keyService != nil && strings.HasPrefix(bearerToken, "sb_") {
apiKey, keyErr := keyService.Authenticate(r.Context(), bearerToken)
if keyErr == nil {
ctx := r.Context()
ctx = ContextWithAPIKey(ctx, apiKey)
ctx = trace.ContextWithOwnerID(ctx, fmt.Sprintf("%d", apiKey.UserID))
ctx := ContextWithAgent(r.Context(), agent)
next.ServeHTTP(w, r.WithContext(ctx))
// If the key has an agent_id, load and set the agent context
if apiKey.AgentID != nil {
agentByID, agentErr := service.GetAgentByID(r.Context(), *apiKey.AgentID)
if agentErr == nil {
ctx = ContextWithAgent(ctx, agentByID)
}
}
slog.Debug("API key authenticated (MCP)",
"key_id", apiKey.ID,
"key_name", apiKey.Name,
"agent_id", apiKey.AgentID,
"remote_addr", r.RemoteAddr,
)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
slog.Warn("MCP authentication failed",
"remote_addr", r.RemoteAddr,
"error", err,
)
http.Error(w, `{"error":"unauthorized","message":"Invalid API key"}`, http.StatusUnauthorized)
})
}
}
// AuthMiddleware creates HTTP middleware that authenticates requests via API key.
func AuthMiddleware(service *AgentService) func(http.Handler) http.Handler {
return AuthMiddlewareWithAPIKeys(service, nil)
}
// AuthMiddlewareWithAPIKeys creates HTTP middleware that authenticates requests
// via agent API keys or the new managed API keys. Authentication is required.
func AuthMiddlewareWithAPIKeys(service *AgentService, keyService *apikeys.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
@@ -79,24 +138,52 @@ func AuthMiddleware(service *AgentService) func(http.Handler) http.Handler {
return
}
apiKey := parts[1]
agent, err := service.Authenticate(r.Context(), apiKey)
if err != nil {
slog.Warn("authentication failed",
bearerToken := parts[1]
// 1. Try existing agent API key auth
agent, err := service.Authenticate(r.Context(), bearerToken)
if err == nil {
slog.Debug("agent authenticated",
"agent", agent.Name,
"remote_addr", r.RemoteAddr,
"error", err,
)
http.Error(w, `{"error":"unauthorized","message":"Invalid API key"}`, http.StatusUnauthorized)
ctx := ContextWithAgent(r.Context(), agent)
ctx = trace.ContextWithOwnerID(ctx, fmt.Sprintf("%d", agent.OwnerID))
next.ServeHTTP(w, r.WithContext(ctx))
return
}
slog.Debug("agent authenticated",
"agent", agent.Name,
"remote_addr", r.RemoteAddr,
)
// 2. Try new managed API key auth (sb_ prefixed keys)
if keyService != nil && strings.HasPrefix(bearerToken, "sb_") {
apiKey, keyErr := keyService.Authenticate(r.Context(), bearerToken)
if keyErr == nil {
ctx := r.Context()
ctx = ContextWithAPIKey(ctx, apiKey)
ctx = trace.ContextWithOwnerID(ctx, fmt.Sprintf("%d", apiKey.UserID))
ctx := ContextWithAgent(r.Context(), agent)
next.ServeHTTP(w, r.WithContext(ctx))
if apiKey.AgentID != nil {
agentByID, agentErr := service.GetAgentByID(r.Context(), *apiKey.AgentID)
if agentErr == nil {
ctx = ContextWithAgent(ctx, agentByID)
}
}
slog.Debug("API key authenticated",
"key_id", apiKey.ID,
"key_name", apiKey.Name,
"agent_id", apiKey.AgentID,
"remote_addr", r.RemoteAddr,
)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
slog.Warn("authentication failed",
"remote_addr", r.RemoteAddr,
"error", err,
)
http.Error(w, `{"error":"unauthorized","message":"Invalid API key"}`, http.StatusUnauthorized)
})
}
}
+12
View File
@@ -125,6 +125,18 @@ func (s *AgentService) GetAgent(ctx context.Context, name string) (*Agent, error
return agent, nil
}
// GetAgentByID returns an agent by ID.
func (s *AgentService) GetAgentByID(ctx context.Context, id int64) (*Agent, error) {
agent, err := s.store.GetAgentByID(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found: %d", id)
}
return nil, err
}
return agent, nil
}
// UpdateAgent updates an agent's display name and/or capabilities.
func (s *AgentService) UpdateAgent(ctx context.Context, name string, displayName string, capabilities json.RawMessage) (*Agent, error) {
agent, err := s.store.GetAgentByName(ctx, name)
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/smart-mcp-proxy/synapbus/internal/apikeys"
)
// APIKeysHandler handles REST API requests for API key management.
type APIKeysHandler struct {
keyService *apikeys.Service
logger *slog.Logger
}
// NewAPIKeysHandler creates a new API keys handler.
func NewAPIKeysHandler(keyService *apikeys.Service) *APIKeysHandler {
return &APIKeysHandler{
keyService: keyService,
logger: slog.Default().With("component", "api.apikeys"),
}
}
// ListKeys handles GET /api/keys.
func (h *APIKeysHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
keys, err := h.keyService.ListKeys(r.Context(), ownerID)
if err != nil {
h.logger.Error("list API keys failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list API keys"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"keys": keys})
}
// CreateKey handles POST /api/keys.
func (h *APIKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
var req struct {
Name string `json:"name"`
AgentID *int64 `json:"agent_id,omitempty"`
Permissions apikeys.Permissions `json:"permissions"`
AllowedChannels []string `json:"allowed_channels"`
ReadOnly bool `json:"read_only"`
ExpiresAt *string `json:"expires_at,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Key name is required"))
return
}
createReq := apikeys.CreateKeyRequest{
UserID: ownerID,
AgentID: req.AgentID,
Name: req.Name,
Permissions: req.Permissions,
AllowedChannels: req.AllowedChannels,
ReadOnly: req.ReadOnly,
}
if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "expires_at must be RFC3339 format"))
return
}
createReq.ExpiresAt = &t
}
key, rawKey, err := h.keyService.CreateKey(r.Context(), createReq)
if err != nil {
h.logger.Error("create API key failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("create_failed", err.Error()))
return
}
// Build MCP config example
mcpConfig := map[string]any{
"mcpServers": map[string]any{
"synapbus": map[string]any{
"url": fmt.Sprintf("http://%s/mcp", r.Host),
"headers": map[string]string{
"Authorization": "Bearer " + rawKey,
},
},
},
}
writeJSON(w, http.StatusCreated, map[string]any{
"key": key,
"api_key": rawKey,
"mcp_config": mcpConfig,
})
}
// GetKey handles GET /api/keys/{id}.
func (h *APIKeysHandler) GetKey(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid key ID"))
return
}
key, err := h.keyService.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "API key not found"))
return
}
if key.UserID != ownerID {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this API key"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"key": key})
}
// RevokeKey handles DELETE /api/keys/{id}.
func (h *APIKeysHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid key ID"))
return
}
// Verify ownership
key, err := h.keyService.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "API key not found"))
return
}
if key.UserID != ownerID {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this API key"))
return
}
if err := h.keyService.RevokeKey(r.Context(), id); err != nil {
h.logger.Error("revoke API key failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("revoke_failed", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
+41
View File
@@ -243,6 +243,7 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
Priority int `json:"priority"`
ChannelID *int64 `json:"channel_id,omitempty"`
Subject string `json:"subject,omitempty"`
ReplyTo *int64 `json:"reply_to,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -273,6 +274,7 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
Priority: req.Priority,
ChannelID: req.ChannelID,
Subject: req.Subject,
ReplyTo: req.ReplyTo,
}
msg, err := h.msgService.SendMessage(r.Context(), req.From, req.To, req.Body, opts)
@@ -384,6 +386,45 @@ func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request)
})
}
// GetReplies handles GET /api/messages/{id}/replies.
func (h *MessagesHandler) GetReplies(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
// Verify the parent message exists and user has access
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
if !h.isAgentOwnedBy(r, msg.FromAgent, ownerID) && !h.isAgentOwnedBy(r, msg.ToAgent, ownerID) {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this message"))
return
}
replies, err := h.msgService.GetReplies(r.Context(), id)
if err != nil {
h.logger.Error("get replies failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get replies"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"replies": replies,
"total": len(replies),
})
}
func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
if agentName == "" {
return false
+16
View File
@@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/smart-mcp-proxy/synapbus/internal/agents"
"github.com/smart-mcp-proxy/synapbus/internal/apikeys"
"github.com/smart-mcp-proxy/synapbus/internal/attachments"
"github.com/smart-mcp-proxy/synapbus/internal/channels"
"github.com/smart-mcp-proxy/synapbus/internal/messaging"
@@ -21,6 +22,7 @@ type RouterConfig struct {
MsgService *messaging.MessagingService
AgentService *agents.AgentService
ChannelService *channels.Service
APIKeyService *apikeys.Service
SSEHub *SSEHub
SessionMiddleware func(http.Handler) http.Handler
}
@@ -84,6 +86,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Get("/api/messages", messagesHandler.ListMessages)
r.Get("/api/messages/search", messagesHandler.SearchMessages)
r.Get("/api/messages/{id}", messagesHandler.GetMessage)
r.Get("/api/messages/{id}/replies", messagesHandler.GetReplies)
r.Post("/api/messages", messagesHandler.SendMessage)
r.Post("/api/messages/{id}/done", messagesHandler.MarkDone)
@@ -99,6 +102,19 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
})
// API Keys
if cfg.APIKeyService != nil {
apiKeysHandler := NewAPIKeysHandler(cfg.APIKeyService)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/api/keys", apiKeysHandler.ListKeys)
r.Post("/api/keys", apiKeysHandler.CreateKey)
r.Get("/api/keys/{id}", apiKeysHandler.GetKey)
r.Delete("/api/keys/{id}", apiKeysHandler.RevokeKey)
})
}
// Channels
if cfg.ChannelService != nil {
channelsHandler := NewChannelsHandler(cfg.ChannelService, cfg.AgentService, cfg.MsgService)
+105
View File
@@ -0,0 +1,105 @@
package apikeys
import (
"context"
"fmt"
"log/slog"
"strings"
)
// Service provides business logic for API key management.
type Service struct {
store Store
logger *slog.Logger
}
// NewService creates a new API key service.
func NewService(store Store) *Service {
return &Service{
store: store,
logger: slog.Default().With("component", "apikeys"),
}
}
// CreateKey creates a new API key with the given parameters.
// Returns the APIKey and the raw key (shown once).
func (s *Service) CreateKey(ctx context.Context, req CreateKeyRequest) (*APIKey, string, error) {
if strings.TrimSpace(req.Name) == "" {
return nil, "", fmt.Errorf("key name is required")
}
key, rawKey, err := s.store.CreateKey(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("create API key: %w", err)
}
s.logger.Info("API key created",
"key_id", key.ID,
"name", key.Name,
"user_id", key.UserID,
"agent_id", key.AgentID,
"read_only", key.ReadOnly,
)
return key, rawKey, nil
}
// ListKeys returns all non-revoked API keys for a user.
func (s *Service) ListKeys(ctx context.Context, userID int64) ([]APIKey, error) {
keys, err := s.store.ListKeys(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list API keys: %w", err)
}
return keys, nil
}
// GetByID returns an API key by ID.
func (s *Service) GetByID(ctx context.Context, id int64) (*APIKey, error) {
key, err := s.store.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get API key: %w", err)
}
return key, nil
}
// Authenticate verifies a raw API key and returns the associated APIKey.
// Also updates the last_used_at timestamp.
func (s *Service) Authenticate(ctx context.Context, rawKey string) (*APIKey, error) {
if !strings.HasPrefix(rawKey, "sb_") {
return nil, fmt.Errorf("invalid API key format")
}
key, err := s.store.Authenticate(ctx, rawKey)
if err != nil {
return nil, err
}
// Update last used asynchronously (best effort)
go func() {
if updateErr := s.store.UpdateLastUsed(context.Background(), key.ID); updateErr != nil {
s.logger.Warn("failed to update last_used_at", "key_id", key.ID, "error", updateErr)
}
}()
return key, nil
}
// RevokeKey soft-deletes an API key.
func (s *Service) RevokeKey(ctx context.Context, id int64) error {
if err := s.store.RevokeKey(ctx, id); err != nil {
return fmt.Errorf("revoke API key: %w", err)
}
s.logger.Info("API key revoked", "key_id", id)
return nil
}
// DeleteKey permanently removes an API key.
func (s *Service) DeleteKey(ctx context.Context, id int64) error {
if err := s.store.DeleteKey(ctx, id); err != nil {
return fmt.Errorf("delete API key: %w", err)
}
s.logger.Info("API key deleted", "key_id", id)
return nil
}
+357
View File
@@ -0,0 +1,357 @@
package apikeys
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
// keyPrefix is prepended to all generated API keys.
const keyPrefixTag = "sb_"
// Store defines the storage interface for API key operations.
type Store interface {
CreateKey(ctx context.Context, req CreateKeyRequest) (*APIKey, string, error)
ListKeys(ctx context.Context, userID int64) ([]APIKey, error)
GetByID(ctx context.Context, id int64) (*APIKey, error)
Authenticate(ctx context.Context, rawKey string) (*APIKey, error)
RevokeKey(ctx context.Context, id int64) error
UpdateLastUsed(ctx context.Context, id int64) error
DeleteKey(ctx context.Context, id int64) error
}
// SQLiteStore implements Store using SQLite.
type SQLiteStore struct {
db *sql.DB
}
// NewSQLiteStore creates a new SQLite-backed API key store.
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
return &SQLiteStore{db: db}
}
// CreateKey generates a new API key, hashes it, and stores it.
// Returns the APIKey record and the raw key (shown once).
func (s *SQLiteStore) CreateKey(ctx context.Context, req CreateKeyRequest) (*APIKey, string, error) {
// Generate random key: sb_ + 48 hex chars (24 random bytes)
rawBytes := make([]byte, 24)
if _, err := rand.Read(rawBytes); err != nil {
return nil, "", fmt.Errorf("generate random key: %w", err)
}
rawHex := hex.EncodeToString(rawBytes)
fullKey := keyPrefixTag + rawHex
prefix := keyPrefixTag + rawHex[:8]
// Hash the full key with bcrypt
hash, err := bcrypt.GenerateFromPassword([]byte(fullKey), bcrypt.DefaultCost)
if err != nil {
return nil, "", fmt.Errorf("hash API key: %w", err)
}
// Marshal permissions and allowed channels to JSON
permsJSON, err := json.Marshal(req.Permissions)
if err != nil {
return nil, "", fmt.Errorf("marshal permissions: %w", err)
}
channels := req.AllowedChannels
if channels == nil {
channels = []string{}
}
channelsJSON, err := json.Marshal(channels)
if err != nil {
return nil, "", fmt.Errorf("marshal allowed channels: %w", err)
}
var agentID sql.NullInt64
if req.AgentID != nil {
agentID = sql.NullInt64{Int64: *req.AgentID, Valid: true}
}
var expiresAt sql.NullTime
if req.ExpiresAt != nil {
expiresAt = sql.NullTime{Time: *req.ExpiresAt, Valid: true}
}
readOnlyInt := 0
if req.ReadOnly {
readOnlyInt = 1
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO api_keys (user_id, agent_id, name, key_prefix, key_hash, permissions, allowed_channels, read_only, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
req.UserID, agentID, req.Name, prefix, string(hash),
string(permsJSON), string(channelsJSON), readOnlyInt, expiresAt,
)
if err != nil {
return nil, "", fmt.Errorf("insert api_key: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, "", fmt.Errorf("get api_key id: %w", err)
}
key := &APIKey{
ID: id,
UserID: req.UserID,
AgentID: req.AgentID,
Name: req.Name,
KeyPrefix: prefix,
Permissions: req.Permissions,
AllowedChannels: channels,
ReadOnly: req.ReadOnly,
ExpiresAt: req.ExpiresAt,
CreatedAt: time.Now(),
}
return key, fullKey, nil
}
// ListKeys returns all API keys for a user (excluding revoked keys' hashes).
func (s *SQLiteStore) ListKeys(ctx context.Context, userID int64) ([]APIKey, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, user_id, agent_id, name, key_prefix, permissions, allowed_channels,
read_only, expires_at, last_used_at, created_at, revoked_at
FROM api_keys
WHERE user_id = ? AND revoked_at IS NULL
ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, fmt.Errorf("query api_keys: %w", err)
}
defer rows.Close()
return scanAPIKeys(rows)
}
// GetByID returns a single API key by ID.
func (s *SQLiteStore) GetByID(ctx context.Context, id int64) (*APIKey, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, user_id, agent_id, name, key_prefix, permissions, allowed_channels,
read_only, expires_at, last_used_at, created_at, revoked_at
FROM api_keys
WHERE id = ?`,
id,
)
return scanAPIKey(row)
}
// Authenticate verifies a raw API key against all non-revoked, non-expired keys.
// Returns the matching APIKey or an error.
func (s *SQLiteStore) Authenticate(ctx context.Context, rawKey string) (*APIKey, error) {
// Only try keys with matching prefix for efficiency
if len(rawKey) < 11 {
return nil, fmt.Errorf("invalid API key format")
}
prefix := rawKey[:11] // "sb_" + 8 hex chars
rows, err := s.db.QueryContext(ctx,
`SELECT id, user_id, agent_id, name, key_prefix, key_hash, permissions, allowed_channels,
read_only, expires_at, last_used_at, created_at, revoked_at
FROM api_keys
WHERE key_prefix = ? AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)`,
prefix,
)
if err != nil {
return nil, fmt.Errorf("query api_keys for auth: %w", err)
}
defer rows.Close()
for rows.Next() {
var key APIKey
var agentID sql.NullInt64
var expiresAt, lastUsedAt, revokedAt sql.NullTime
var permsStr, channelsStr, keyHash string
var readOnlyInt int
err := rows.Scan(
&key.ID, &key.UserID, &agentID, &key.Name, &key.KeyPrefix,
&keyHash, &permsStr, &channelsStr,
&readOnlyInt, &expiresAt, &lastUsedAt, &key.CreatedAt, &revokedAt,
)
if err != nil {
return nil, fmt.Errorf("scan api_key: %w", err)
}
// bcrypt compare
if err := bcrypt.CompareHashAndPassword([]byte(keyHash), []byte(rawKey)); err != nil {
continue
}
// Match found - populate fields
if agentID.Valid {
key.AgentID = &agentID.Int64
}
if expiresAt.Valid {
key.ExpiresAt = &expiresAt.Time
}
if lastUsedAt.Valid {
key.LastUsedAt = &lastUsedAt.Time
}
if revokedAt.Valid {
key.RevokedAt = &revokedAt.Time
}
key.ReadOnly = readOnlyInt != 0
if err := json.Unmarshal([]byte(permsStr), &key.Permissions); err != nil {
return nil, fmt.Errorf("unmarshal permissions: %w", err)
}
if err := json.Unmarshal([]byte(channelsStr), &key.AllowedChannels); err != nil {
key.AllowedChannels = []string{}
}
return &key, nil
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate api_keys: %w", err)
}
return nil, fmt.Errorf("invalid API key")
}
// RevokeKey soft-deletes an API key by setting revoked_at.
func (s *SQLiteStore) RevokeKey(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx,
`UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL`,
id,
)
if err != nil {
return fmt.Errorf("revoke api_key: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("API key not found or already revoked")
}
return nil
}
// UpdateLastUsed updates the last_used_at timestamp.
func (s *SQLiteStore) UpdateLastUsed(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?`,
id,
)
return err
}
// DeleteKey permanently removes an API key.
func (s *SQLiteStore) DeleteKey(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx,
`DELETE FROM api_keys WHERE id = ?`,
id,
)
if err != nil {
return fmt.Errorf("delete api_key: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("API key not found")
}
return nil
}
// scanAPIKeys scans multiple API key rows.
func scanAPIKeys(rows *sql.Rows) ([]APIKey, error) {
var keys []APIKey
for rows.Next() {
var key APIKey
var agentID sql.NullInt64
var expiresAt, lastUsedAt, revokedAt sql.NullTime
var permsStr, channelsStr string
var readOnlyInt int
err := rows.Scan(
&key.ID, &key.UserID, &agentID, &key.Name, &key.KeyPrefix,
&permsStr, &channelsStr,
&readOnlyInt, &expiresAt, &lastUsedAt, &key.CreatedAt, &revokedAt,
)
if err != nil {
return nil, fmt.Errorf("scan api_key: %w", err)
}
if agentID.Valid {
key.AgentID = &agentID.Int64
}
if expiresAt.Valid {
key.ExpiresAt = &expiresAt.Time
}
if lastUsedAt.Valid {
key.LastUsedAt = &lastUsedAt.Time
}
if revokedAt.Valid {
key.RevokedAt = &revokedAt.Time
}
key.ReadOnly = readOnlyInt != 0
if err := json.Unmarshal([]byte(permsStr), &key.Permissions); err != nil {
return nil, fmt.Errorf("unmarshal permissions: %w", err)
}
if err := json.Unmarshal([]byte(channelsStr), &key.AllowedChannels); err != nil {
key.AllowedChannels = []string{}
}
keys = append(keys, key)
}
if keys == nil {
keys = []APIKey{}
}
return keys, rows.Err()
}
// scanAPIKey scans a single API key from sql.Row.
func scanAPIKey(row *sql.Row) (*APIKey, error) {
var key APIKey
var agentID sql.NullInt64
var expiresAt, lastUsedAt, revokedAt sql.NullTime
var permsStr, channelsStr string
var readOnlyInt int
err := row.Scan(
&key.ID, &key.UserID, &agentID, &key.Name, &key.KeyPrefix,
&permsStr, &channelsStr,
&readOnlyInt, &expiresAt, &lastUsedAt, &key.CreatedAt, &revokedAt,
)
if err != nil {
return nil, err
}
if agentID.Valid {
key.AgentID = &agentID.Int64
}
if expiresAt.Valid {
key.ExpiresAt = &expiresAt.Time
}
if lastUsedAt.Valid {
key.LastUsedAt = &lastUsedAt.Time
}
if revokedAt.Valid {
key.RevokedAt = &revokedAt.Time
}
key.ReadOnly = readOnlyInt != 0
if err := json.Unmarshal([]byte(permsStr), &key.Permissions); err != nil {
return nil, fmt.Errorf("unmarshal permissions: %w", err)
}
if err := json.Unmarshal([]byte(channelsStr), &key.AllowedChannels); err != nil {
key.AllowedChannels = []string{}
}
return &key, nil
}
+38
View File
@@ -0,0 +1,38 @@
// Package apikeys provides API key management with permissions for SynapBus.
package apikeys
import "time"
// APIKey represents a managed API key with permissions.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AgentID *int64 `json:"agent_id,omitempty"` // nil = user-level
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Permissions Permissions `json:"permissions"`
AllowedChannels []string `json:"allowed_channels"`
ReadOnly bool `json:"read_only"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// Permissions defines the access level for an API key.
type Permissions struct {
Read bool `json:"read"`
Write bool `json:"write"`
Admin bool `json:"admin"`
}
// CreateKeyRequest holds parameters for creating a new API key.
type CreateKeyRequest struct {
UserID int64 `json:"user_id"`
AgentID *int64 `json:"agent_id,omitempty"`
Name string `json:"name"`
Permissions Permissions `json:"permissions"`
AllowedChannels []string `json:"allowed_channels"`
ReadOnly bool `json:"read_only"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
+6 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/smart-mcp-proxy/synapbus/internal/channels"
"github.com/smart-mcp-proxy/synapbus/internal/messaging"
"github.com/smart-mcp-proxy/synapbus/internal/search"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
// MCPServer wraps the mcp-go server with SynapBus services.
@@ -71,7 +72,11 @@ func NewMCPServer(
server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
// Propagate agent identity from HTTP auth to MCP context
if agent, ok := agents.AgentFromContext(r.Context()); ok {
return ContextWithAgentName(ctx, agent.Name)
ctx = ContextWithAgentName(ctx, agent.Name)
// Propagate owner ID for trace recording
if ownerID, ok := trace.OwnerIDFromContext(r.Context()); ok {
ctx = trace.ContextWithOwnerID(ctx, ownerID)
}
}
return ctx
}),
+8
View File
@@ -62,6 +62,7 @@ func (tr *ToolRegistrar) sendMessageTool() mcp.Tool {
mcp.WithNumber("priority", mcp.Description("Message priority (1-10, default 5)"), mcp.Min(1), mcp.Max(10)),
mcp.WithString("metadata", mcp.Description("JSON metadata object (optional)")),
mcp.WithNumber("channel_id", mcp.Description("Channel ID for channel messages (optional)")),
mcp.WithNumber("reply_to", mcp.Description("ID of the message to reply to (optional, for threading)")),
)
}
@@ -163,11 +164,18 @@ func (tr *ToolRegistrar) handleSendMessage(ctx context.Context, req mcp.CallTool
channelID = &v
}
var replyTo *int64
if rtID := req.GetInt("reply_to", 0); rtID > 0 {
v := int64(rtID)
replyTo = &v
}
opts := messaging.SendOptions{
Subject: subject,
Priority: priority,
Metadata: metadataStr,
ChannelID: channelID,
ReplyTo: replyTo,
}
msg, err := tr.msgService.SendMessage(ctx, agentName, to, body, opts)
+1
View File
@@ -7,6 +7,7 @@ type SendOptions struct {
Metadata string `json:"metadata,omitempty"`
ChannelID *int64 `json:"channel_id,omitempty"`
ConversationID *int64 `json:"conversation_id,omitempty"`
ReplyTo *int64 `json:"reply_to,omitempty"`
}
// ReadOptions configures inbox reading behavior.
+10
View File
@@ -103,6 +103,7 @@ func (s *MessagingService) SendMessage(ctx context.Context, from, to, body strin
FromAgent: from,
ToAgent: to,
ChannelID: opts.ChannelID,
ReplyTo: opts.ReplyTo,
Body: body,
Priority: priority,
Status: StatusPending,
@@ -326,6 +327,15 @@ func (s *MessagingService) GetMessageByID(ctx context.Context, id int64) (*Messa
return msg, nil
}
// GetReplies returns all messages that are replies to the given message.
func (s *MessagingService) GetReplies(ctx context.Context, messageID int64) ([]*Message, error) {
replies, err := s.store.GetReplies(ctx, messageID)
if err != nil {
return nil, fmt.Errorf("get replies: %w", err)
}
return replies, 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)
+40 -12
View File
@@ -22,6 +22,7 @@ type MessageStore interface {
SearchMessages(ctx context.Context, agentName, query string, opts SearchOptions) ([]*Message, error)
GetConversation(ctx context.Context, id int64) (*Conversation, error)
GetConversationMessages(ctx context.Context, conversationID int64) ([]*Message, error)
GetReplies(ctx context.Context, messageID int64) ([]*Message, error)
AgentExists(ctx context.Context, agentName string) (bool, error)
}
@@ -86,10 +87,15 @@ func (s *SQLiteMessageStore) InsertMessage(ctx context.Context, msg *Message) er
toAgent = sql.NullString{String: msg.ToAgent, Valid: true}
}
var replyTo sql.NullInt64
if msg.ReplyTo != nil {
replyTo = sql.NullInt64{Int64: *msg.ReplyTo, Valid: true}
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, body, priority, status, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
msg.ConversationID, msg.FromAgent, toAgent, msg.ChannelID, msg.Body, msg.Priority, msg.Status, string(metadata),
`INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, reply_to, body, priority, status, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
msg.ConversationID, msg.FromAgent, toAgent, msg.ChannelID, replyTo, msg.Body, msg.Priority, msg.Status, string(metadata),
)
if err != nil {
return fmt.Errorf("insert message: %w", err)
@@ -147,7 +153,7 @@ func (s *SQLiteMessageStore) GetInboxMessages(ctx context.Context, agentName str
query := fmt.Sprintf(
`SELECT m.id, m.conversation_id, m.from_agent, m.to_agent, m.channel_id,
m.body, m.priority, m.status, m.metadata, m.claimed_by, m.claimed_at,
m.created_at, m.updated_at
m.created_at, m.updated_at, m.reply_to
FROM messages m
WHERE %s
ORDER BY m.priority DESC, m.created_at ASC
@@ -263,7 +269,7 @@ func (s *SQLiteMessageStore) ClaimMessages(ctx context.Context, agentName string
fmt.Sprintf(
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at
created_at, updated_at, reply_to
FROM messages
WHERE id IN (%s)
ORDER BY priority DESC, created_at ASC`,
@@ -314,7 +320,7 @@ func (s *SQLiteMessageStore) GetMessageByID(ctx context.Context, id int64) (*Mes
row := s.db.QueryRowContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at
created_at, updated_at, reply_to
FROM messages WHERE id = ?`, id,
)
return scanMessage(row)
@@ -368,7 +374,7 @@ func (s *SQLiteMessageStore) SearchMessages(ctx context.Context, agentName, quer
querySQL := fmt.Sprintf(
`SELECT m.id, m.conversation_id, m.from_agent, m.to_agent, m.channel_id,
m.body, m.priority, m.status, m.metadata, m.claimed_by, m.claimed_at,
m.created_at, m.updated_at
m.created_at, m.updated_at, m.reply_to
FROM messages m
%s
WHERE %s
@@ -409,7 +415,7 @@ func (s *SQLiteMessageStore) GetConversationMessages(ctx context.Context, conver
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at
created_at, updated_at, reply_to
FROM messages WHERE conversation_id = ?
ORDER BY created_at ASC`, conversationID,
)
@@ -421,6 +427,22 @@ func (s *SQLiteMessageStore) GetConversationMessages(ctx context.Context, conver
return scanMessages(rows)
}
func (s *SQLiteMessageStore) GetReplies(ctx context.Context, messageID int64) ([]*Message, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages WHERE reply_to = ?
ORDER BY created_at ASC`, messageID,
)
if err != nil {
return nil, fmt.Errorf("get replies: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) AgentExists(ctx context.Context, agentName string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx,
@@ -453,14 +475,14 @@ func scanMessages(rows *sql.Rows) ([]*Message, error) {
func scanMessageFromRows(rows *sql.Rows) (*Message, error) {
var msg Message
var toAgent, claimedBy sql.NullString
var channelID sql.NullInt64
var channelID, replyTo sql.NullInt64
var claimedAt sql.NullTime
var metadata string
err := rows.Scan(
&msg.ID, &msg.ConversationID, &msg.FromAgent, &toAgent, &channelID,
&msg.Body, &msg.Priority, &msg.Status, &metadata, &claimedBy, &claimedAt,
&msg.CreatedAt, &msg.UpdatedAt,
&msg.CreatedAt, &msg.UpdatedAt, &replyTo,
)
if err != nil {
return nil, fmt.Errorf("scan message: %w", err)
@@ -472,6 +494,9 @@ func scanMessageFromRows(rows *sql.Rows) (*Message, error) {
if channelID.Valid {
msg.ChannelID = &channelID.Int64
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.Int64
}
if claimedBy.Valid {
msg.ClaimedBy = claimedBy.String
}
@@ -487,14 +512,14 @@ func scanMessageFromRows(rows *sql.Rows) (*Message, error) {
func scanMessage(row *sql.Row) (*Message, error) {
var msg Message
var toAgent, claimedBy sql.NullString
var channelID sql.NullInt64
var channelID, replyTo sql.NullInt64
var claimedAt sql.NullTime
var metadata string
err := row.Scan(
&msg.ID, &msg.ConversationID, &msg.FromAgent, &toAgent, &channelID,
&msg.Body, &msg.Priority, &msg.Status, &metadata, &claimedBy, &claimedAt,
&msg.CreatedAt, &msg.UpdatedAt,
&msg.CreatedAt, &msg.UpdatedAt, &replyTo,
)
if err != nil {
return nil, err
@@ -506,6 +531,9 @@ func scanMessage(row *sql.Row) (*Message, error) {
if channelID.Valid {
msg.ChannelID = &channelID.Int64
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.Int64
}
if claimedBy.Valid {
msg.ClaimedBy = claimedBy.String
}
+1
View File
@@ -21,6 +21,7 @@ type Message struct {
FromAgent string `json:"from_agent"`
ToAgent string `json:"to_agent,omitempty"`
ChannelID *int64 `json:"channel_id,omitempty"`
ReplyTo *int64 `json:"reply_to,omitempty"`
Body string `json:"body"`
Priority int `json:"priority"`
Status string `json:"status"`
+22
View File
@@ -0,0 +1,22 @@
-- API key management with permissions
-- Supports user-level and agent-level API keys with fine-grained permissions
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
agent_id INTEGER REFERENCES agents(id), -- NULL = user-level key
name TEXT NOT NULL, -- human-readable label
key_prefix TEXT NOT NULL, -- first 8 chars for identification
key_hash TEXT NOT NULL, -- bcrypt hash of full key
permissions TEXT NOT NULL DEFAULT '{}', -- JSON: {"read": true, "write": true, "admin": false}
allowed_channels TEXT NOT NULL DEFAULT '[]', -- JSON array of channel names, empty = all
read_only INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMP, -- NULL = never expires
last_used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP -- soft-delete
);
CREATE INDEX idx_api_keys_user ON api_keys(user_id);
CREATE INDEX idx_api_keys_agent ON api_keys(agent_id);
CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix);
+5
View File
@@ -0,0 +1,5 @@
-- Thread/reply support for messages
-- Adds reply_to column to messages for threaded conversations
ALTER TABLE messages ADD COLUMN reply_to INTEGER REFERENCES messages(id);
CREATE INDEX idx_messages_reply_to ON messages(reply_to);
+23 -4
View File
@@ -18,6 +18,21 @@ type TraceEntry struct {
Error string
}
type ctxKey string
const ownerIDKey ctxKey = "trace_owner_id"
// ContextWithOwnerID returns a new context with the trace owner ID set.
func ContextWithOwnerID(ctx context.Context, ownerID string) context.Context {
return context.WithValue(ctx, ownerIDKey, ownerID)
}
// OwnerIDFromContext extracts the trace owner ID from context, if set.
func OwnerIDFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ownerIDKey).(string)
return v, ok
}
// MetricsRecorder is an optional interface for recording trace metrics.
type MetricsRecorder interface {
IncTrace(action string)
@@ -51,9 +66,11 @@ func (t *Tracer) SetMetrics(m MetricsRecorder) {
t.metrics = m
}
// Record enqueues a trace entry for async storage (no owner ID — legacy API).
// Record enqueues a trace entry for async storage.
// Extracts owner ID from context if available.
func (t *Tracer) Record(ctx context.Context, agentName, action string, details any) {
t.RecordWithOwner(ctx, "", agentName, action, details)
ownerID, _ := OwnerIDFromContext(ctx)
t.RecordWithOwner(ctx, ownerID, agentName, action, details)
}
// RecordWithOwner enqueues a trace entry with an explicit owner ID.
@@ -80,9 +97,11 @@ func (t *Tracer) RecordWithOwner(ctx context.Context, ownerID, agentName, action
)
}
// RecordError enqueues a trace entry with an error (no owner ID — legacy API).
// RecordError enqueues a trace entry with an error.
// Extracts owner ID from context if available.
func (t *Tracer) RecordError(ctx context.Context, agentName, action string, details any, traceErr error) {
t.RecordErrorWithOwner(ctx, "", agentName, action, details, traceErr)
ownerID, _ := OwnerIDFromContext(ctx)
t.RecordErrorWithOwner(ctx, ownerID, agentName, action, details, traceErr)
}
// RecordErrorWithOwner enqueues a trace entry with an error and explicit owner ID.
+14 -19
View File
@@ -5,37 +5,32 @@
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SynapBus</title>
<script>
// Prevent flash of wrong theme
const theme = localStorage.getItem('synapbus-theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
<link href="/_app/immutable/entry/start.CtCTsMzJ.js" rel="modulepreload">
<link href="/_app/immutable/chunks/i_hwctCb.js" rel="modulepreload">
<link href="/_app/immutable/chunks/-TS2BfOP.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CjVAzn99.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B3rAT5Sd.js" rel="modulepreload">
<link href="/_app/immutable/chunks/PZmxHExn.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CQyUWFZw.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DKQxuds6.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.Da9TM-Vg.js" rel="modulepreload">
<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.ogsShEri.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bqa26XJb.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DUxJZl-L.js" rel="modulepreload">
<link href="/_app/immutable/chunks/8XOTKOPf.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DBvUSRxw.js" rel="modulepreload">
<link href="/_app/immutable/chunks/ja5lAnVg.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Dmx72vI6.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.CiI1z1Wl.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_1hzqm4w = {
__sveltekit_19emkgk = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.CtCTsMzJ.js"),
import("/_app/immutable/entry/app.Da9TM-Vg.js")
import("/_app/immutable/entry/start.ogsShEri.js"),
import("/_app/immutable/entry/app.CiI1z1Wl.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+210
View File
@@ -0,0 +1,210 @@
"""Claude agent loop (Anthropic SDK tool-use) with logging for reports."""
from __future__ import annotations
import json
import time
import traceback
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import anthropic
from .mcp_client import SynapBusMCP
@dataclass
class ToolCall:
"""Record of a single tool call."""
agent: str
tool: str
input: Dict[str, Any]
output: Dict[str, Any]
success: bool
timestamp: float
duration: float = 0.0
@dataclass
class AgentRun:
"""Record of a full agent conversation turn."""
agent_name: str
system_prompt: str
user_prompt: str
response_text: str
tool_calls: List[ToolCall]
input_tokens: int
output_tokens: int
duration: float
error: Optional[str] = None
@dataclass
class TestResult:
"""Result of a single test scenario."""
name: str
description: str
status: str # "pass", "fail", "error"
agents: List[str]
runs: List[AgentRun]
verifications: List[Dict[str, Any]]
error: Optional[str]
duration: float
total_input_tokens: int
total_output_tokens: int
def run_agent(
claude: anthropic.Anthropic,
mcp: SynapBusMCP,
agent_name: str,
system_prompt: str,
user_prompt: str,
tools: List[Dict[str, Any]],
model: str = "claude-sonnet-4-6",
max_tool_rounds: int = 5,
timeout: float = 60.0,
) -> AgentRun:
"""Run a Claude agent with SynapBus MCP tools.
Pattern from dialog-engine: iterate up to max_tool_rounds allowing
tool use. On the final round, omit tools to force a text response.
Returns an AgentRun with all details for the report.
"""
messages: List[Dict[str, Any]] = [{"role": "user", "content": user_prompt}]
all_tool_calls: List[ToolCall] = []
total_input_tokens = 0
total_output_tokens = 0
start_time = time.time()
for round_num in range(max_tool_rounds + 1):
api_kwargs: Dict[str, Any] = dict(
model=model,
max_tokens=2048,
system=system_prompt,
messages=messages,
)
# Allow tool use except on final round
if round_num < max_tool_rounds:
api_kwargs["tools"] = tools
try:
response = claude.messages.create(**api_kwargs)
except Exception as e:
return AgentRun(
agent_name=agent_name,
system_prompt=system_prompt,
user_prompt=user_prompt,
response_text="",
tool_calls=all_tool_calls,
input_tokens=total_input_tokens,
output_tokens=total_output_tokens,
duration=time.time() - start_time,
error="API error: {}".format(str(e)),
)
total_input_tokens += response.usage.input_tokens
total_output_tokens += response.usage.output_tokens
has_tool_use = any(b.type == "tool_use" for b in response.content)
if not has_tool_use or round_num == max_tool_rounds:
# Extract final text
text_parts = []
for block in response.content:
if block.type == "text":
text_parts.append(block.text)
final_text = "\n".join(text_parts) if text_parts else "(no response)"
print(" [{}] {}".format(agent_name, final_text[:200]))
return AgentRun(
agent_name=agent_name,
system_prompt=system_prompt,
user_prompt=user_prompt,
response_text=final_text,
tool_calls=all_tool_calls,
input_tokens=total_input_tokens,
output_tokens=total_output_tokens,
duration=time.time() - start_time,
)
# Process tool calls
assistant_content: List[Dict[str, Any]] = []
for block in response.content:
if block.type == "text":
assistant_content.append({"type": "text", "text": block.text})
print(" [{}] {}".format(agent_name, block.text[:120]))
elif block.type == "tool_use":
assistant_content.append({
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
})
messages.append({"role": "assistant", "content": assistant_content})
# Execute tools via MCP
tool_results: List[Dict[str, Any]] = []
for block in response.content:
if block.type == "tool_use":
tool_input_str = json.dumps(block.input)[:80]
print(" [{}] -> {}({})".format(agent_name, block.name, tool_input_str))
tc_start = time.time()
try:
tool_result = mcp.call_tool(block.name, block.input)
result_str = json.dumps(tool_result)
tc_duration = time.time() - tc_start
print(" [{}] <- {}".format(agent_name, result_str[:120]))
is_error = isinstance(tool_result, dict) and tool_result.get("_mcp_error", False)
all_tool_calls.append(ToolCall(
agent=agent_name,
tool=block.name,
input=block.input,
output=tool_result,
success=not is_error,
timestamp=time.time(),
duration=tc_duration,
))
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result_str,
})
except Exception as e:
tc_duration = time.time() - tc_start
error_msg = str(e)
print(" [{}] <- ERROR: {}".format(agent_name, error_msg))
all_tool_calls.append(ToolCall(
agent=agent_name,
tool=block.name,
input=block.input,
output={"error": error_msg},
success=False,
timestamp=time.time(),
duration=tc_duration,
))
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": "Error: {}".format(error_msg),
"is_error": True,
})
messages.append({"role": "user", "content": tool_results})
# Should not reach here
return AgentRun(
agent_name=agent_name,
system_prompt=system_prompt,
user_prompt=user_prompt,
response_text="(max rounds exceeded)",
tool_calls=all_tool_calls,
input_tokens=total_input_tokens,
output_tokens=total_output_tokens,
duration=time.time() - start_time,
)
def make_verification(check: str, passed: bool, detail: str = "") -> Dict[str, Any]:
"""Create a verification entry for the report."""
return {"check": check, "passed": passed, "detail": detail}
+53
View File
@@ -0,0 +1,53 @@
"""3-tier Anthropic authentication: API key, OAuth env, macOS Keychain."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import anthropic
def create_anthropic_client() -> anthropic.Anthropic:
"""Create Anthropic client with 3-tier auth fallback.
1. ANTHROPIC_API_KEY env var (standard API key)
2. CLAUDE_CODE_OAUTH_TOKEN env var (OAuth token)
3. macOS Keychain (Claude Code subscription credentials)
"""
# Tier 1: Standard API key
api_key = os.environ.get("ANTHROPIC_API_KEY")
if api_key:
print(" Auth: using ANTHROPIC_API_KEY")
return anthropic.Anthropic(api_key=api_key)
# Tier 2: OAuth token from env
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
# Tier 3: macOS Keychain (Claude Code stores credentials as JSON)
if not oauth_token:
try:
raw = subprocess.check_output(
["security", "find-generic-password",
"-s", "Claude Code-credentials", "-w"],
text=True, stderr=subprocess.DEVNULL,
).strip()
if raw:
creds = json.loads(raw)
oauth_token = creds.get("claudeAiOauth", {}).get("accessToken")
if oauth_token:
print(" Auth: using macOS Keychain (Claude Code subscription)")
except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError):
pass
if oauth_token:
return anthropic.Anthropic(
auth_token=oauth_token,
default_headers={"anthropic-beta": "oauth-2025-04-20"},
)
print("ERROR: No Anthropic credentials found.")
print(" Set ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN,")
print(" or ensure Claude Code is logged in (macOS Keychain).")
sys.exit(1)
+115
View File
@@ -0,0 +1,115 @@
"""SynapBus MCP Streamable HTTP client with request/response logging."""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import httpx
@dataclass
class MCPLog:
"""Single MCP request/response log entry."""
method: str
params: Optional[Dict[str, Any]]
response: Any
error: Optional[str]
duration: float
timestamp: float
class SynapBusMCP:
"""Thin MCP client for SynapBus Streamable HTTP transport with logging."""
def __init__(self, base_url: str, api_key: Optional[str] = None):
self.url = "{}/mcp".format(base_url)
self.api_key = api_key
self.session_id: Optional[str] = None
self._req_id = 0
self._client = httpx.Client(timeout=30)
self.logs: List[MCPLog] = []
def _next_id(self) -> int:
self._req_id += 1
return self._req_id
def _headers(self) -> Dict[str, str]:
h: Dict[str, str] = {"Content-Type": "application/json"}
if self.api_key:
h["Authorization"] = "Bearer {}".format(self.api_key)
if self.session_id:
h["Mcp-Session-Id"] = self.session_id
return h
def _rpc(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
body: Dict[str, Any] = {
"jsonrpc": "2.0",
"id": self._next_id(),
"method": method,
}
if params:
body["params"] = params
start = time.time()
error_str: Optional[str] = None
response: Any = None
try:
resp = self._client.post(self.url, json=body, headers=self._headers())
resp.raise_for_status()
if sid := resp.headers.get("Mcp-Session-Id"):
self.session_id = sid
response = resp.json()
return response
except Exception as e:
error_str = str(e)
raise
finally:
self.logs.append(MCPLog(
method=method,
params=params,
response=response,
error=error_str,
duration=time.time() - start,
timestamp=time.time(),
))
def initialize(self) -> Dict[str, Any]:
return self._rpc("initialize", {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "synapbus-e2e-test", "version": "2.0"},
})
def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Call an MCP tool. Returns parsed JSON result or error dict."""
result = self._rpc("tools/call", {"name": name, "arguments": arguments})
if "error" in result:
return result
content = result.get("result", {}).get("content", [])
# Check for isError flag in result
is_error = result.get("result", {}).get("isError", False)
for block in content:
if block.get("type") == "text":
try:
parsed = json.loads(block["text"])
if is_error:
parsed["_mcp_error"] = True
return parsed
except json.JSONDecodeError:
return {"_raw": block["text"], "_mcp_error": is_error}
return result
def call_tool_raw(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Call an MCP tool, returning the full JSON-RPC response."""
return self._rpc("tools/call", {"name": name, "arguments": arguments})
def list_tools(self) -> List[Dict[str, Any]]:
result = self._rpc("tools/list")
return result.get("result", {}).get("tools", [])
def close(self) -> None:
self._client.close()
+692
View File
@@ -0,0 +1,692 @@
"""HTML report generator for SynapBus E2E test results."""
from __future__ import annotations
import html
import json
import os
import time
from typing import Any, Dict, List, Optional
from .agent_runner import TestResult, AgentRun, ToolCall
def _truncate(s: str, max_len: int = 500) -> str:
if len(s) <= max_len:
return s
return s[:max_len] + "..."
def _json_pretty(data: Any, max_len: int = 500) -> str:
try:
raw = json.dumps(data, indent=2, default=str)
except (TypeError, ValueError):
raw = str(data)
return html.escape(_truncate(raw, max_len))
def _status_badge(status: str) -> str:
colors = {
"pass": "#10b981",
"fail": "#ef4444",
"error": "#f59e0b",
}
bg = colors.get(status, "#6b7280")
return '<span class="badge" style="background:{bg}">{label}</span>'.format(
bg=bg, label=html.escape(status.upper()))
def _bool_icon(passed: bool) -> str:
if passed:
return '<span style="color:#10b981;font-weight:bold">PASS</span>'
return '<span style="color:#ef4444;font-weight:bold">FAIL</span>'
def _format_duration(seconds: float) -> str:
if seconds < 1:
return "{:.0f}ms".format(seconds * 1000)
return "{:.1f}s".format(seconds)
def _format_tokens(n: int) -> str:
if n >= 1000:
return "{:.1f}k".format(n / 1000)
return str(n)
def _estimate_cost(input_tokens: int, output_tokens: int, model: str) -> float:
"""Estimate cost in USD. Sonnet pricing: $3/M input, $15/M output."""
if "opus" in model:
return (input_tokens * 15 + output_tokens * 75) / 1_000_000
if "haiku" in model:
return (input_tokens * 0.25 + output_tokens * 1.25) / 1_000_000
# Default Sonnet
return (input_tokens * 3 + output_tokens * 15) / 1_000_000
def generate_report(
results: List[TestResult],
model: str,
base_url: str,
total_duration: float,
output_path: str,
) -> str:
"""Generate a self-contained HTML report file."""
passed = sum(1 for r in results if r.status == "pass")
failed = sum(1 for r in results if r.status == "fail")
errored = sum(1 for r in results if r.status == "error")
total_input = sum(r.total_input_tokens for r in results)
total_output = sum(r.total_output_tokens for r in results)
total_cost = _estimate_cost(total_input, total_output, model)
total_tool_calls = sum(len(tc) for r in results for run in r.runs for tc in [run.tool_calls])
scenarios_html = []
for idx, result in enumerate(results):
scenarios_html.append(_render_scenario(result, idx, model))
report_html = TEMPLATE.format(
timestamp=time.strftime("%Y-%m-%d %H:%M:%S"),
model=html.escape(model),
base_url=html.escape(base_url),
total=len(results),
passed=passed,
failed=failed,
errored=errored,
total_duration=_format_duration(total_duration),
total_input=_format_tokens(total_input),
total_output=_format_tokens(total_output),
total_cost="{:.4f}".format(total_cost),
total_tool_calls=total_tool_calls,
scenarios="\n".join(scenarios_html),
pass_rate="{:.0f}".format(100 * passed / len(results)) if results else "0",
)
with open(output_path, "w") as f:
f.write(report_html)
return output_path
def _render_scenario(result: TestResult, idx: int, model: str) -> str:
cost = _estimate_cost(result.total_input_tokens, result.total_output_tokens, model)
# Render verifications
verifications_html = ""
if result.verifications:
rows = []
for v in result.verifications:
rows.append(
'<tr><td>{icon}</td><td>{check}</td><td class="mono">{detail}</td></tr>'.format(
icon=_bool_icon(v.get("passed", False)),
check=html.escape(v.get("check", "")),
detail=html.escape(_truncate(v.get("detail", ""), 200)),
)
)
verifications_html = """
<h4>Verifications</h4>
<table class="verification-table">
<tr><th>Status</th><th>Check</th><th>Detail</th></tr>
{rows}
</table>
""".format(rows="\n".join(rows))
# Render agent runs
runs_html_parts = []
for run in result.runs:
runs_html_parts.append(_render_agent_run(run))
# Error section
error_html = ""
if result.error:
error_html = """
<div class="error-block">
<h4>Error</h4>
<pre>{error}</pre>
</div>
""".format(error=html.escape(result.error))
border_color = {"pass": "#10b981", "fail": "#ef4444", "error": "#f59e0b"}.get(
result.status, "#6b7280")
return """
<div class="scenario-card" style="border-left: 4px solid {border_color}" id="scenario-{idx}">
<div class="scenario-header" onclick="toggleSection('scenario-body-{idx}')">
<div class="scenario-title">
{badge} <span>{name}</span>
</div>
<div class="scenario-meta">
<span class="meta-item">{duration}</span>
<span class="meta-item">{input_tokens} in / {output_tokens} out</span>
<span class="meta-item">${cost}</span>
<span class="meta-item">{agent_count} agents</span>
</div>
</div>
<div class="scenario-body" id="scenario-body-{idx}">
<p class="description">{description}</p>
<div class="agents-list">Agents: {agents}</div>
{verifications}
{error}
<div class="runs-section">
<h4>Agent Runs</h4>
{runs}
</div>
</div>
</div>
""".format(
border_color=border_color,
idx=idx,
badge=_status_badge(result.status),
name=html.escape(result.name),
duration=_format_duration(result.duration),
input_tokens=_format_tokens(result.total_input_tokens),
output_tokens=_format_tokens(result.total_output_tokens),
cost="{:.4f}".format(cost),
agent_count=len(result.agents),
description=html.escape(result.description),
agents=", ".join(html.escape(a) for a in result.agents),
verifications=verifications_html,
error=error_html,
runs="\n".join(runs_html_parts),
)
def _render_agent_run(run: AgentRun) -> str:
# Render tool calls
tc_rows = []
for tc in run.tool_calls:
status_cls = "tc-pass" if tc.success else "tc-fail"
tc_id = "tc-{}-{}".format(id(tc), int(tc.timestamp * 1000))
tc_rows.append("""
<div class="tool-call {status_cls}">
<div class="tc-header" onclick="toggleSection('{tc_id}')">
<span class="tc-tool">{tool}</span>
<span class="tc-status">{status}</span>
<span class="tc-duration">{duration}</span>
</div>
<div class="tc-body" id="{tc_id}" style="display:none">
<div class="tc-section">
<strong>Input:</strong>
<pre>{input}</pre>
</div>
<div class="tc-section">
<strong>Output:</strong>
<pre>{output}</pre>
</div>
</div>
</div>
""".format(
status_cls=status_cls,
tc_id=tc_id,
tool=html.escape(tc.tool),
status="OK" if tc.success else "ERR",
duration=_format_duration(tc.duration),
input=_json_pretty(tc.input),
output=_json_pretty(tc.output),
))
error_html = ""
if run.error:
error_html = '<div class="run-error">Error: {}</div>'.format(html.escape(run.error))
return """
<div class="agent-run">
<div class="run-header">
<span class="agent-name">{agent_name}</span>
<span class="run-meta">{duration} | {input_tokens} in / {output_tokens} out | {tc_count} tool calls</span>
</div>
{error}
<div class="run-prompt">
<details>
<summary>Prompts</summary>
<div class="prompt-section">
<strong>System:</strong>
<pre>{system_prompt}</pre>
</div>
<div class="prompt-section">
<strong>User:</strong>
<pre>{user_prompt}</pre>
</div>
</details>
</div>
<div class="run-response">
<details>
<summary>Response</summary>
<pre>{response}</pre>
</details>
</div>
<div class="tool-calls">
{tool_calls}
</div>
</div>
""".format(
agent_name=html.escape(run.agent_name),
duration=_format_duration(run.duration),
input_tokens=_format_tokens(run.input_tokens),
output_tokens=_format_tokens(run.output_tokens),
tc_count=len(run.tool_calls),
error=error_html,
system_prompt=html.escape(_truncate(run.system_prompt, 300)),
user_prompt=html.escape(_truncate(run.user_prompt, 500)),
response=html.escape(_truncate(run.response_text, 800)),
tool_calls="\n".join(tc_rows),
)
TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SynapBus E2E Test Report</title>
<style>
:root {{
--bg: #0f172a;
--surface: #1e293b;
--surface2: #334155;
--text: #e2e8f0;
--text-dim: #94a3b8;
--border: #475569;
--green: #10b981;
--red: #ef4444;
--yellow: #f59e0b;
--blue: #3b82f6;
--purple: #8b5cf6;
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}}
h1 {{ font-size: 1.8rem; margin-bottom: 0.5rem; }}
h2 {{ font-size: 1.3rem; margin-bottom: 1rem; color: var(--text-dim); }}
h3 {{ font-size: 1.1rem; margin-bottom: 0.5rem; }}
h4 {{ font-size: 0.95rem; margin: 1rem 0 0.5rem; color: var(--text-dim); }}
.header {{
text-align: center;
padding: 2rem 0;
border-bottom: 1px solid var(--border);
margin-bottom: 2rem;
}}
.header .subtitle {{
color: var(--text-dim);
font-size: 0.9rem;
}}
.dashboard {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}}
.stat-card {{
background: var(--surface);
border-radius: 8px;
padding: 1.2rem;
text-align: center;
}}
.stat-card .stat-value {{
font-size: 2rem;
font-weight: 700;
}}
.stat-card .stat-label {{
font-size: 0.8rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.stat-value.green {{ color: var(--green); }}
.stat-value.red {{ color: var(--red); }}
.stat-value.yellow {{ color: var(--yellow); }}
.stat-value.blue {{ color: var(--blue); }}
.stat-value.purple {{ color: var(--purple); }}
.filter-bar {{
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}}
.filter-btn {{
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem 1rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}}
.filter-btn:hover {{ background: var(--surface2); }}
.filter-btn.active {{ background: var(--blue); border-color: var(--blue); }}
.scenario-card {{
background: var(--surface);
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
}}
.scenario-header {{
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.2rem;
cursor: pointer;
transition: background 0.2s;
flex-wrap: wrap;
gap: 0.5rem;
}}
.scenario-header:hover {{ background: var(--surface2); }}
.scenario-title {{
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
font-size: 1rem;
}}
.scenario-meta {{
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: var(--text-dim);
}}
.meta-item {{
white-space: nowrap;
}}
.badge {{
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
color: white;
letter-spacing: 0.05em;
}}
.scenario-body {{
padding: 0 1.2rem 1.2rem;
display: none;
}}
.scenario-body.open {{ display: block; }}
.description {{
color: var(--text-dim);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}}
.agents-list {{
font-size: 0.85rem;
color: var(--text-dim);
margin-bottom: 1rem;
font-family: 'SF Mono', 'Fira Code', monospace;
}}
.verification-table {{
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-bottom: 1rem;
}}
.verification-table th {{
text-align: left;
padding: 0.4rem 0.6rem;
background: var(--surface2);
color: var(--text-dim);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
}}
.verification-table td {{
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
}}
.mono {{ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.8rem; }}
.error-block {{
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--red);
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
}}
.error-block pre {{
color: var(--red);
white-space: pre-wrap;
word-break: break-word;
font-size: 0.8rem;
}}
.agent-run {{
background: var(--bg);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}}
.run-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}}
.agent-name {{
font-weight: 700;
color: var(--purple);
font-size: 0.95rem;
}}
.run-meta {{
font-size: 0.8rem;
color: var(--text-dim);
}}
.run-error {{
background: rgba(239, 68, 68, 0.1);
color: var(--red);
padding: 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}}
details {{
margin-bottom: 0.5rem;
}}
summary {{
cursor: pointer;
color: var(--text-dim);
font-size: 0.85rem;
padding: 0.3rem 0;
}}
summary:hover {{ color: var(--text); }}
pre {{
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.8rem;
background: var(--surface2);
padding: 0.6rem;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
margin-top: 0.3rem;
}}
.tool-call {{
border-left: 3px solid var(--border);
padding: 0.3rem 0 0.3rem 0.8rem;
margin-bottom: 0.4rem;
}}
.tool-call.tc-pass {{ border-left-color: var(--green); }}
.tool-call.tc-fail {{ border-left-color: var(--red); }}
.tc-header {{
display: flex;
gap: 1rem;
align-items: center;
cursor: pointer;
font-size: 0.85rem;
padding: 0.2rem 0;
}}
.tc-header:hover {{ color: var(--blue); }}
.tc-tool {{
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
}}
.tc-status {{ font-size: 0.75rem; }}
.tc-duration {{ font-size: 0.75rem; color: var(--text-dim); }}
.tc-body {{ padding: 0.5rem 0; }}
.tc-section {{ margin-bottom: 0.5rem; }}
.tc-section strong {{ font-size: 0.8rem; color: var(--text-dim); }}
.footer {{
text-align: center;
padding: 2rem 0;
color: var(--text-dim);
font-size: 0.8rem;
border-top: 1px solid var(--border);
margin-top: 2rem;
}}
</style>
</head>
<body>
<div class="header">
<h1>SynapBus E2E Test Report</h1>
<p class="subtitle">Generated {timestamp} | Model: {model} | Server: {base_url}</p>
</div>
<div class="dashboard">
<div class="stat-card">
<div class="stat-value">{total}</div>
<div class="stat-label">Scenarios</div>
</div>
<div class="stat-card">
<div class="stat-value green">{passed}</div>
<div class="stat-label">Passed</div>
</div>
<div class="stat-card">
<div class="stat-value red">{failed}</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat-card">
<div class="stat-value yellow">{errored}</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat-card">
<div class="stat-value blue">{total_duration}</div>
<div class="stat-label">Duration</div>
</div>
<div class="stat-card">
<div class="stat-value purple">{total_input} / {total_output}</div>
<div class="stat-label">Tokens In/Out</div>
</div>
<div class="stat-card">
<div class="stat-value">${total_cost}</div>
<div class="stat-label">Est. Cost</div>
</div>
<div class="stat-card">
<div class="stat-value">{total_tool_calls}</div>
<div class="stat-label">Tool Calls</div>
</div>
</div>
<div class="filter-bar">
<button class="filter-btn active" onclick="filterScenarios('all')">All ({total})</button>
<button class="filter-btn" onclick="filterScenarios('pass')">Passed ({passed})</button>
<button class="filter-btn" onclick="filterScenarios('fail')">Failed ({failed})</button>
<button class="filter-btn" onclick="filterScenarios('error')">Errors ({errored})</button>
</div>
<div id="scenarios">
{scenarios}
</div>
<div class="footer">
SynapBus E2E Test Suite | Pass Rate: {pass_rate}%
</div>
<script>
function toggleSection(id) {{
var el = document.getElementById(id);
if (!el) return;
if (el.style.display === 'none' || !el.style.display) {{
el.style.display = 'block';
el.classList.add('open');
}} else {{
el.style.display = 'none';
el.classList.remove('open');
}}
}}
function filterScenarios(status) {{
var cards = document.querySelectorAll('.scenario-card');
var btns = document.querySelectorAll('.filter-btn');
btns.forEach(function(b) {{ b.classList.remove('active'); }});
event.target.classList.add('active');
cards.forEach(function(card) {{
if (status === 'all') {{
card.style.display = 'block';
}} else {{
var badge = card.querySelector('.badge');
if (badge && badge.textContent.toLowerCase() === status) {{
card.style.display = 'block';
}} else {{
card.style.display = 'none';
}}
}}
}});
}}
// Auto-expand failed scenarios
document.querySelectorAll('.scenario-card').forEach(function(card) {{
var badge = card.querySelector('.badge');
if (badge && (badge.textContent === 'FAIL' || badge.textContent === 'ERROR')) {{
var body = card.querySelector('.scenario-body');
if (body) {{
body.style.display = 'block';
body.classList.add('open');
}}
}}
}});
</script>
</body>
</html>"""
+87
View File
@@ -0,0 +1,87 @@
"""SynapBus server lifecycle management (start/stop/health)."""
from __future__ import annotations
import os
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import httpx
def find_free_port() -> int:
"""Find an available TCP port."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return s.getsockname()[1]
def find_binary() -> str:
"""Locate the synapbus binary."""
# Try project root
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
candidate = os.path.join(repo_root, "synapbus")
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
# Try PATH
from shutil import which
found = which("synapbus")
if found:
return found
print("ERROR: synapbus binary not found. Run 'make build' first.")
sys.exit(1)
def start_server(port: int) -> tuple:
"""Start SynapBus server, return (process, data_dir, port)."""
binary = find_binary()
data_dir = tempfile.mkdtemp(prefix="synapbus-e2e-")
proc = subprocess.Popen(
[binary, "serve", "--port", str(port), "--data", data_dir],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Wait for server to be healthy
for _ in range(30):
try:
resp = httpx.get("http://localhost:{}/health".format(port), timeout=2)
if resp.status_code == 200:
return proc, data_dir, port
except httpx.ConnectError:
pass
time.sleep(0.5)
proc.terminate()
shutil.rmtree(data_dir, ignore_errors=True)
print("ERROR: Server failed to start within 15 seconds")
sys.exit(1)
def stop_server(proc: subprocess.Popen, data_dir: str) -> None:
"""Stop server and clean up."""
try:
proc.send_signal(signal.SIGTERM)
proc.wait(timeout=10)
except Exception:
proc.kill()
shutil.rmtree(data_dir, ignore_errors=True)
def wait_for_server(base_url: str, timeout: int = 15) -> bool:
"""Wait for an already-running server to become healthy."""
for _ in range(timeout * 2):
try:
resp = httpx.get("{}/health".format(base_url), timeout=2)
if resp.status_code == 200:
return True
except httpx.ConnectError:
pass
time.sleep(0.5)
return False
+101
View File
@@ -0,0 +1,101 @@
"""Register users and agents, return credentials."""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import httpx
@dataclass
class AgentCredentials:
"""Credentials for a registered agent."""
name: str
display_name: str
api_key: str
agent_type: str
capabilities: Dict[str, Any]
def register_user(base_url: str, username: str = "e2e_tester",
password: str = "testpass123456") -> httpx.Cookies:
"""Register a test user (ignore if exists) and return session cookies."""
client = httpx.Client(timeout=10)
try:
# Register (ignore if exists)
client.post("{}/auth/register".format(base_url), json={
"username": username,
"password": password,
"display_name": "E2E Tester",
})
# Login
resp = client.post("{}/auth/login".format(base_url), json={
"username": username,
"password": password,
})
if resp.status_code != 200:
print(" [!] Login failed (status {}). Server may need a fresh data directory.".format(
resp.status_code))
sys.exit(1)
return resp.cookies
finally:
client.close()
def register_agent(base_url: str, cookies: httpx.Cookies,
name: str, display_name: str,
agent_type: str = "ai",
capabilities: Optional[Dict[str, Any]] = None) -> AgentCredentials:
"""Register a single agent via the REST API, return credentials."""
caps = capabilities or {}
client = httpx.Client(timeout=10)
try:
resp = client.post("{}/api/agents".format(base_url), json={
"name": name,
"display_name": display_name,
"type": agent_type,
"capabilities": caps,
}, cookies=cookies)
data = resp.json()
api_key = data.get("api_key", "")
if not api_key:
print(" [!] Agent registration failed for '{}': {}".format(name, data))
sys.exit(1)
return AgentCredentials(
name=name,
display_name=display_name,
api_key=api_key,
agent_type=agent_type,
capabilities=caps,
)
finally:
client.close()
def register_agents(base_url: str,
agents: List[Dict[str, Any]],
username: str = "e2e_tester",
password: str = "testpass123456") -> List[AgentCredentials]:
"""Register user + multiple agents. Returns list of AgentCredentials.
Each dict in agents should have: name, display_name, and optionally
type and capabilities.
"""
cookies = register_user(base_url, username, password)
result = []
for agent_def in agents:
cred = register_agent(
base_url, cookies,
name=agent_def["name"],
display_name=agent_def["display_name"],
agent_type=agent_def.get("type", "ai"),
capabilities=agent_def.get("capabilities"),
)
result.append(cred)
return result
+388
View File
@@ -0,0 +1,388 @@
"""All 25 SynapBus MCP tool JSON schemas for Claude tool-use."""
from __future__ import annotations
from typing import Any, Dict, List
# -- Messaging tools (5) --
SEND_MESSAGE = {
"name": "send_message",
"description": "Send a direct message to another agent or to a channel",
"input_schema": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Name of the recipient agent"},
"body": {"type": "string", "description": "Message body text"},
"subject": {"type": "string", "description": "Conversation subject (optional)"},
"priority": {"type": "number", "description": "Message priority (1-10, default 5)", "minimum": 1, "maximum": 10},
"metadata": {"type": "string", "description": "JSON metadata object (optional)"},
"channel_id": {"type": "number", "description": "Channel ID for channel messages (optional)"},
},
"required": ["to", "body"],
},
}
READ_INBOX = {
"name": "read_inbox",
"description": "Read messages from your inbox",
"input_schema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Maximum number of messages to return (default 50)"},
"status_filter": {"type": "string", "description": "Filter by status: pending, processing, done, failed"},
"include_read": {"type": "boolean", "description": "Include previously read messages (default false)"},
"min_priority": {"type": "number", "description": "Minimum priority filter (1-10)"},
"from_agent": {"type": "string", "description": "Filter by sender agent name"},
},
},
}
CLAIM_MESSAGES = {
"name": "claim_messages",
"description": "Atomically claim pending messages for processing",
"input_schema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Maximum number of messages to claim (default 10)"},
},
},
}
MARK_DONE = {
"name": "mark_done",
"description": "Mark a claimed message as done or failed",
"input_schema": {
"type": "object",
"properties": {
"message_id": {"type": "number", "description": "ID of the message to mark"},
"status": {"type": "string", "enum": ["done", "failed"], "description": "New status: 'done' or 'failed'"},
"reason": {"type": "string", "description": "Failure reason (only for status='failed')"},
},
"required": ["message_id"],
},
}
SEARCH_MESSAGES = {
"name": "search_messages",
"description": "Search messages using semantic or full-text search. Returns messages ranked by relevance.",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query string"},
"limit": {"type": "number", "description": "Maximum results to return (default 10)"},
"min_priority": {"type": "number", "description": "Minimum priority filter (1-10)"},
"from_agent": {"type": "string", "description": "Filter by sender agent name"},
"status": {"type": "string", "description": "Filter by message status"},
"search_mode": {"type": "string", "description": "Search mode: 'auto', 'semantic', or 'fulltext'"},
},
},
}
# -- Agent tools (4) --
REGISTER_AGENT = {
"name": "register_agent",
"description": "Register a new agent and receive an API key",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Unique agent name"},
"display_name": {"type": "string", "description": "Human-readable display name"},
"type": {"type": "string", "description": "Agent type: 'ai' or 'human' (default 'ai')"},
"capabilities": {"type": "string", "description": "JSON capabilities object"},
},
"required": ["name"],
},
}
DISCOVER_AGENTS = {
"name": "discover_agents",
"description": "Discover agents by capability keywords",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Capability keyword to search for"},
},
},
}
UPDATE_AGENT = {
"name": "update_agent",
"description": "Update your display name or capabilities",
"input_schema": {
"type": "object",
"properties": {
"display_name": {"type": "string", "description": "New display name"},
"capabilities": {"type": "string", "description": "New JSON capabilities object"},
},
},
}
DEREGISTER_AGENT = {
"name": "deregister_agent",
"description": "Deregister the authenticated agent (soft delete)",
"input_schema": {
"type": "object",
"properties": {},
},
}
# -- Channel tools (8) --
CREATE_CHANNEL = {
"name": "create_channel",
"description": "Create a new channel for group communication",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Unique channel name (alphanumeric, hyphens, underscores, max 64 chars)"},
"description": {"type": "string", "description": "Channel description"},
"topic": {"type": "string", "description": "Current channel topic"},
"type": {"type": "string", "description": "Channel type: 'standard', 'blackboard', or 'auction' (default 'standard')"},
"is_private": {"type": "boolean", "description": "Whether the channel is private (invite-only). Default false"},
},
"required": ["name"],
},
}
JOIN_CHANNEL = {
"name": "join_channel",
"description": "Join an existing channel",
"input_schema": {
"type": "object",
"properties": {
"channel_id": {"type": "number", "description": "ID of the channel to join"},
"channel_name": {"type": "string", "description": "Name of the channel to join (alternative to channel_id)"},
},
},
}
LEAVE_CHANNEL = {
"name": "leave_channel",
"description": "Leave a channel you are a member of",
"input_schema": {
"type": "object",
"properties": {
"channel_id": {"type": "number", "description": "ID of the channel to leave"},
"channel_name": {"type": "string", "description": "Name of the channel to leave (alternative to channel_id)"},
},
},
}
LIST_CHANNELS = {
"name": "list_channels",
"description": "List all channels visible to you (public + your private channels)",
"input_schema": {
"type": "object",
"properties": {},
},
}
INVITE_TO_CHANNEL = {
"name": "invite_to_channel",
"description": "Invite an agent to a channel (only owner can invite to private channels)",
"input_schema": {
"type": "object",
"properties": {
"channel_id": {"type": "number", "description": "ID of the channel"},
"channel_name": {"type": "string", "description": "Name of the channel (alternative to channel_id)"},
"agent_name": {"type": "string", "description": "Name of the agent to invite"},
},
"required": ["agent_name"],
},
}
KICK_FROM_CHANNEL = {
"name": "kick_from_channel",
"description": "Remove an agent from a channel (only owner can kick)",
"input_schema": {
"type": "object",
"properties": {
"channel_id": {"type": "number", "description": "ID of the channel"},
"channel_name": {"type": "string", "description": "Name of the channel (alternative to channel_id)"},
"agent_name": {"type": "string", "description": "Name of the agent to kick"},
},
"required": ["agent_name"],
},
}
SEND_CHANNEL_MESSAGE = {
"name": "send_channel_message",
"description": "Send a message to all members of a channel",
"input_schema": {
"type": "object",
"properties": {
"channel_id": {"type": "number", "description": "ID of the channel"},
"channel_name": {"type": "string", "description": "Name of the channel (alternative to channel_id)"},
"body": {"type": "string", "description": "Message body text"},
"priority": {"type": "number", "description": "Message priority (1-10, default 5)", "minimum": 1, "maximum": 10},
"metadata": {"type": "string", "description": "JSON metadata object (optional)"},
},
"required": ["body"],
},
}
UPDATE_CHANNEL = {
"name": "update_channel",
"description": "Update channel topic or description (only owner can update)",
"input_schema": {
"type": "object",
"properties": {
"channel_id": {"type": "number", "description": "ID of the channel"},
"channel_name": {"type": "string", "description": "Name of the channel (alternative to channel_id)"},
"topic": {"type": "string", "description": "New channel topic"},
"description": {"type": "string", "description": "New channel description"},
},
},
}
# -- Swarm / Task tools (5) --
POST_TASK = {
"name": "post_task",
"description": "Post a task to an auction channel for agents to bid on",
"input_schema": {
"type": "object",
"properties": {
"channel_name": {"type": "string", "description": "Name of the auction channel"},
"title": {"type": "string", "description": "Task title"},
"description": {"type": "string", "description": "Task description"},
"requirements": {"type": "string", "description": "JSON object of task requirements"},
"deadline": {"type": "string", "description": "Task deadline in ISO 8601 format"},
},
"required": ["channel_name", "title"],
},
}
BID_TASK = {
"name": "bid_task",
"description": "Submit a bid on an open task in an auction channel",
"input_schema": {
"type": "object",
"properties": {
"task_id": {"type": "number", "description": "ID of the task to bid on"},
"capabilities": {"type": "string", "description": "JSON object of your relevant capabilities"},
"time_estimate": {"type": "string", "description": "Estimated time to complete"},
"message": {"type": "string", "description": "Message to the task poster explaining your bid"},
},
"required": ["task_id"],
},
}
ACCEPT_BID = {
"name": "accept_bid",
"description": "Accept a bid on a task you posted, assigning it to the bidding agent",
"input_schema": {
"type": "object",
"properties": {
"task_id": {"type": "number", "description": "ID of the task"},
"bid_id": {"type": "number", "description": "ID of the bid to accept"},
},
"required": ["task_id", "bid_id"],
},
}
COMPLETE_TASK = {
"name": "complete_task",
"description": "Mark a task as completed (only the assigned agent can do this)",
"input_schema": {
"type": "object",
"properties": {
"task_id": {"type": "number", "description": "ID of the task to complete"},
},
"required": ["task_id"],
},
}
LIST_TASKS = {
"name": "list_tasks",
"description": "List tasks in an auction channel, optionally filtered by status",
"input_schema": {
"type": "object",
"properties": {
"channel_name": {"type": "string", "description": "Name of the auction channel"},
"status": {"type": "string", "description": "Filter by task status: open, assigned, completed, cancelled"},
},
"required": ["channel_name"],
},
}
# -- Attachment tools (3) --
UPLOAD_ATTACHMENT = {
"name": "upload_attachment",
"description": "Upload a file attachment (base64-encoded). Returns SHA-256 hash.",
"input_schema": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Base64-encoded file content"},
"filename": {"type": "string", "description": "Original filename"},
"mime_type": {"type": "string", "description": "MIME type override"},
"message_id": {"type": "number", "description": "Message ID to attach to (optional)"},
},
"required": ["content"],
},
}
DOWNLOAD_ATTACHMENT = {
"name": "download_attachment",
"description": "Download an attachment by its SHA-256 hash",
"input_schema": {
"type": "object",
"properties": {
"hash": {"type": "string", "description": "SHA-256 hash of the attachment"},
},
"required": ["hash"],
},
}
GC_ATTACHMENTS = {
"name": "gc_attachments",
"description": "Run garbage collection to remove orphaned attachments",
"input_schema": {
"type": "object",
"properties": {},
},
}
# -- Tool sets for different scenarios --
MESSAGING_TOOLS: List[Dict[str, Any]] = [
SEND_MESSAGE, READ_INBOX, CLAIM_MESSAGES, MARK_DONE, SEARCH_MESSAGES,
]
AGENT_TOOLS: List[Dict[str, Any]] = [
REGISTER_AGENT, DISCOVER_AGENTS, UPDATE_AGENT, DEREGISTER_AGENT,
]
CHANNEL_TOOLS: List[Dict[str, Any]] = [
CREATE_CHANNEL, JOIN_CHANNEL, LEAVE_CHANNEL, LIST_CHANNELS,
INVITE_TO_CHANNEL, KICK_FROM_CHANNEL, SEND_CHANNEL_MESSAGE, UPDATE_CHANNEL,
]
TASK_TOOLS: List[Dict[str, Any]] = [
POST_TASK, BID_TASK, ACCEPT_BID, COMPLETE_TASK, LIST_TASKS,
]
ATTACHMENT_TOOLS: List[Dict[str, Any]] = [
UPLOAD_ATTACHMENT, DOWNLOAD_ATTACHMENT, GC_ATTACHMENTS,
]
ALL_TOOLS: List[Dict[str, Any]] = (
MESSAGING_TOOLS + AGENT_TOOLS + CHANNEL_TOOLS + TASK_TOOLS + ATTACHMENT_TOOLS
)
def get_tools_for_scenario(scenario: str) -> List[Dict[str, Any]]:
"""Return an appropriate subset of tools for a scenario."""
tool_map = {
"direct_messaging": MESSAGING_TOOLS + [DISCOVER_AGENTS],
"channels": MESSAGING_TOOLS + CHANNEL_TOOLS,
"task_auction": MESSAGING_TOOLS + CHANNEL_TOOLS + TASK_TOOLS,
"access_control": CHANNEL_TOOLS + MESSAGING_TOOLS,
"agent_discovery": MESSAGING_TOOLS + AGENT_TOOLS,
"blackboard": MESSAGING_TOOLS + CHANNEL_TOOLS,
"audit_trail": MESSAGING_TOOLS + CHANNEL_TOOLS,
}
return tool_map.get(scenario, ALL_TOOLS)
+9
View File
@@ -0,0 +1,9 @@
[project]
name = "synapbus-e2e"
version = "0.1.0"
description = "SynapBus E2E test suite"
requires-python = ">=3.10"
dependencies = [
"anthropic>=0.50.0",
"httpx>=0.27.0",
]
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
SynapBus E2E Test Suite
Comprehensive end-to-end tests using Claude-powered agents communicating
through SynapBus MCP. Generates a self-contained HTML report.
Prerequisites:
pip install anthropic httpx
Usage:
# Auto-start server, run all scenarios, generate report:
python tests/e2e/run_tests.py --auto-server
# Run against existing server:
python tests/e2e/run_tests.py --port 8080
# Run single scenario:
python tests/e2e/run_tests.py --auto-server --scenario direct_messaging
# Keep server running for UI inspection:
python tests/e2e/run_tests.py --auto-server --keep-server
# Use a different model:
python tests/e2e/run_tests.py --auto-server --model claude-haiku-4-5
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import time
from typing import List, Optional
# Ensure the test package is importable
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from e2e.lib.auth import create_anthropic_client
from e2e.lib.server import find_free_port, start_server, stop_server, wait_for_server
from e2e.lib.report import generate_report
from e2e.lib.agent_runner import TestResult
# Import scenarios
from e2e.scenarios import test_direct_messaging
from e2e.scenarios import test_channels
from e2e.scenarios import test_task_auction
from e2e.scenarios import test_access_control
from e2e.scenarios import test_agent_discovery
from e2e.scenarios import test_blackboard
from e2e.scenarios import test_audit_trail
# Scenario registry: (name, module)
SCENARIOS = [
("direct_messaging", test_direct_messaging),
("channels", test_channels),
("task_auction", test_task_auction),
("access_control", test_access_control),
("agent_discovery", test_agent_discovery),
("blackboard", test_blackboard),
("audit_trail", test_audit_trail),
]
def print_banner(base_url: str, model: str, scenarios: List[str]) -> None:
print()
print("=" * 60)
print(" SynapBus E2E Test Suite")
print("=" * 60)
print(" Server: {}".format(base_url))
print(" Model: {}".format(model))
print(" Scenarios: {} ({})".format(len(scenarios), ", ".join(scenarios)))
print("=" * 60)
def print_summary(results: List[TestResult], total_duration: float, report_path: str) -> None:
passed = sum(1 for r in results if r.status == "pass")
failed = sum(1 for r in results if r.status == "fail")
errored = sum(1 for r in results if r.status == "error")
total_input = sum(r.total_input_tokens for r in results)
total_output = sum(r.total_output_tokens for r in results)
print()
print("=" * 60)
print(" RESULTS")
print("=" * 60)
for r in results:
icon = {"pass": " OK ", "fail": " FAIL", "error": " ERR "}[r.status]
print(" [{}] {} ({:.1f}s)".format(icon, r.name, r.duration))
if r.status != "pass" and r.error:
# Print first line of error
first_line = r.error.strip().split("\n")[-1]
print(" {}".format(first_line[:80]))
print()
print(" Total: {} passed, {} failed, {} errors".format(passed, failed, errored))
print(" Time: {:.1f}s".format(total_duration))
if total_input > 0 or total_output > 0:
print(" Tokens: {} in / {} out".format(total_input, total_output))
# Estimate cost (Sonnet pricing)
cost = (total_input * 3 + total_output * 15) / 1_000_000
print(" Cost: ${:.4f}".format(cost))
print(" Report: {}".format(os.path.abspath(report_path)))
print("=" * 60)
def main() -> int:
parser = argparse.ArgumentParser(
description="SynapBus E2E Test Suite",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--port", type=int, default=0,
help="SynapBus server port (0 = auto-assign)")
parser.add_argument("--auto-server", action="store_true",
help="Auto-start and stop SynapBus server")
parser.add_argument("--keep-server", action="store_true",
help="Keep server running after tests for UI inspection")
parser.add_argument("--model", default="claude-sonnet-4-6",
help="Claude model to use (default: claude-sonnet-4-6)")
parser.add_argument("--scenario", type=str, default=None,
help="Run a single scenario by name")
parser.add_argument("--report", type=str, default="report.html",
help="Output HTML report path (default: report.html)")
args = parser.parse_args()
server_proc = None
data_dir = None
# Determine which scenarios to run
if args.scenario:
selected = [(name, mod) for name, mod in SCENARIOS if name == args.scenario]
if not selected:
available = [name for name, _ in SCENARIOS]
print("ERROR: Unknown scenario '{}'. Available: {}".format(
args.scenario, ", ".join(available)))
return 1
else:
selected = SCENARIOS
# Start or connect to server
if args.auto_server or args.port == 0:
port = args.port if args.port != 0 else find_free_port()
print("Starting SynapBus server on port {}...".format(port))
server_proc, data_dir, port = start_server(port)
print(" Server started (pid={})".format(server_proc.pid))
else:
port = args.port
base_url = "http://localhost:{}".format(port)
if not wait_for_server(base_url, timeout=5):
print("ERROR: Server at {} is not responding".format(base_url))
return 1
base_url = "http://localhost:{}".format(port)
try:
scenario_names = [name for name, _ in selected]
print_banner(base_url, args.model, scenario_names)
# Authenticate with Anthropic
print("\n Authenticating with Anthropic...")
claude = create_anthropic_client()
# Run scenarios
results: List[TestResult] = []
total_start = time.time()
for i, (name, module) in enumerate(selected):
print("\n--- [{}/{}] {} ---".format(i + 1, len(selected), name))
result = module.run(claude, base_url, args.model)
results.append(result)
status_str = {"pass": "PASS", "fail": "FAIL", "error": "ERROR"}[result.status]
print(" => {} ({:.1f}s)".format(status_str, result.duration))
total_duration = time.time() - total_start
# Generate report
report_path = generate_report(
results=results,
model=args.model,
base_url=base_url,
total_duration=total_duration,
output_path=args.report,
)
print_summary(results, total_duration, report_path)
# Keep server running if requested
if args.keep_server and server_proc:
print("\n Server running at {} -- press Ctrl+C to stop".format(base_url))
try:
signal.pause()
except (KeyboardInterrupt, AttributeError):
# AttributeError: signal.pause not available on some platforms
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
print("\nStopping server...")
# Return exit code
if all(r.status == "pass" for r in results):
return 0
return 1
finally:
if server_proc and not args.keep_server:
print("Stopping server...")
stop_server(server_proc, data_dir)
elif server_proc and args.keep_server:
stop_server(server_proc, data_dir)
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+189
View File
@@ -0,0 +1,189 @@
"""Scenario: Access control for private channels.
3 agents (acl_owner, acl_member, acl_outsider):
- Owner creates private channel
- Owner invites Member
- Member joins
- Outsider tries to join (should fail)
- Outsider tries to send channel message (should fail)
- Member tries to kick Owner (should fail)
- Member tries to invite Outsider (should fail)
- Owner kicks Member
This test does NOT use Claude agents -- it directly calls MCP tools
to test error responses (cheaper and deterministic).
"""
from __future__ import annotations
import time
import traceback
from typing import Any, Dict, List
import anthropic
from ..lib.agent_runner import AgentRun, TestResult, make_verification
from ..lib.mcp_client import SynapBusMCP
from ..lib.setup import register_agents
SCENARIO_NAME = "access_control"
DESCRIPTION = "Private channel access control: unauthorized join, send, kick, invite all return errors"
def _is_error(result: Dict[str, Any]) -> bool:
"""Check if an MCP tool result indicates an error."""
if result.get("_mcp_error"):
return True
if "error" in result:
return True
# Check for error in raw response
if result.get("_raw", ""):
return "error" in result["_raw"].lower() or "failed" in result["_raw"].lower()
return False
def _has_error_keyword(result: Dict[str, Any]) -> bool:
"""Check if result text contains error-related keywords."""
text = str(result).lower()
return any(kw in text for kw in ["error", "failed", "denied", "unauthorized",
"not a member", "not the owner", "not invited",
"permission", "forbidden", "private"])
def run(claude: anthropic.Anthropic, base_url: str, model: str) -> TestResult:
start = time.time()
agents_list: List[str] = ["acl_owner", "acl_member", "acl_outsider"]
runs: List[AgentRun] = [] # No Claude runs in this scenario
verifications: List[Dict[str, Any]] = []
try:
creds = register_agents(base_url, [
{"name": "acl_owner", "display_name": "Channel Owner",
"capabilities": {"admin": True}},
{"name": "acl_member", "display_name": "Channel Member",
"capabilities": {"research": True}},
{"name": "acl_outsider", "display_name": "Outsider",
"capabilities": {"hacking": True}},
])
owner_cred, member_cred, outsider_cred = creds
owner_mcp = SynapBusMCP(base_url, owner_cred.api_key)
member_mcp = SynapBusMCP(base_url, member_cred.api_key)
outsider_mcp = SynapBusMCP(base_url, outsider_cred.api_key)
owner_mcp.initialize()
member_mcp.initialize()
outsider_mcp.initialize()
# 1. Owner creates private channel
print("\n [acl] Owner creates private channel...")
create_result = owner_mcp.call_tool("create_channel", {
"name": "secret-ops",
"description": "Top secret operations",
"type": "standard",
"is_private": True,
})
ch_created = create_result.get("is_private", False) is True
verifications.append(make_verification(
"Private channel created", ch_created,
str(create_result)[:200]))
# 2. Owner invites Member
print(" [acl] Owner invites member...")
invite_result = owner_mcp.call_tool("invite_to_channel", {
"channel_name": "secret-ops",
"agent_name": "acl_member",
})
invited = invite_result.get("status") == "invited"
verifications.append(make_verification("Owner invited member", invited,
str(invite_result)[:200]))
# 3. Member joins
print(" [acl] Member joins channel...")
member_join = member_mcp.call_tool("join_channel", {"channel_name": "secret-ops"})
member_joined = member_join.get("status") == "joined"
verifications.append(make_verification("Member joined channel", member_joined,
str(member_join)[:200]))
# 4. Outsider tries to join (should fail)
print(" [acl] Outsider tries to join (should fail)...")
outsider_join = outsider_mcp.call_tool("join_channel", {"channel_name": "secret-ops"})
outsider_blocked = _has_error_keyword(outsider_join)
verifications.append(make_verification(
"Outsider join rejected", outsider_blocked,
str(outsider_join)[:200]))
# 5. Outsider tries to send channel message (should fail)
print(" [acl] Outsider tries to send message (should fail)...")
outsider_msg = outsider_mcp.call_tool("send_channel_message", {
"channel_name": "secret-ops",
"body": "I should not be able to send this",
})
outsider_msg_blocked = _has_error_keyword(outsider_msg)
verifications.append(make_verification(
"Outsider message rejected", outsider_msg_blocked,
str(outsider_msg)[:200]))
# 6. Member tries to kick Owner (should fail)
print(" [acl] Member tries to kick owner (should fail)...")
member_kick = member_mcp.call_tool("kick_from_channel", {
"channel_name": "secret-ops",
"agent_name": "acl_owner",
})
member_kick_blocked = _has_error_keyword(member_kick)
verifications.append(make_verification(
"Member kick-owner rejected", member_kick_blocked,
str(member_kick)[:200]))
# 7. Member tries to invite Outsider (should fail for private channel)
print(" [acl] Member tries to invite outsider (should fail)...")
member_invite = member_mcp.call_tool("invite_to_channel", {
"channel_name": "secret-ops",
"agent_name": "acl_outsider",
})
member_invite_blocked = _has_error_keyword(member_invite)
verifications.append(make_verification(
"Member invite rejected", member_invite_blocked,
str(member_invite)[:200]))
# 8. Owner kicks Member (should succeed)
print(" [acl] Owner kicks member...")
owner_kick = owner_mcp.call_tool("kick_from_channel", {
"channel_name": "secret-ops",
"agent_name": "acl_member",
})
owner_kicked = owner_kick.get("status") == "kicked"
verifications.append(make_verification(
"Owner kicked member", owner_kicked,
str(owner_kick)[:200]))
owner_mcp.close()
member_mcp.close()
outsider_mcp.close()
all_passed = all(v["passed"] for v in verifications)
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="pass" if all_passed else "fail",
agents=agents_list,
runs=runs,
verifications=verifications,
error=None,
duration=time.time() - start,
total_input_tokens=0,
total_output_tokens=0,
)
except Exception as e:
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="error",
agents=agents_list,
runs=runs,
verifications=verifications,
error=traceback.format_exc(),
duration=time.time() - start,
total_input_tokens=0,
total_output_tokens=0,
)
+140
View File
@@ -0,0 +1,140 @@
"""Scenario: Agent discovery by capabilities.
4 agents with different capabilities:
- disc_researcher (research, writing)
- disc_coder (python, golang, devops)
- disc_analyst (data_analysis, statistics)
- disc_translator (translation, languages)
Researcher discovers agents with "analysis" capability,
then sends a targeted message to the discovered analyst.
"""
from __future__ import annotations
import time
import traceback
from typing import Any, Dict, List
import anthropic
from ..lib.agent_runner import AgentRun, TestResult, ToolCall, make_verification, run_agent
from ..lib.mcp_client import SynapBusMCP
from ..lib.setup import register_agents
from ..lib.tools import get_tools_for_scenario
SCENARIO_NAME = "agent_discovery"
DESCRIPTION = "Four agents with different capabilities: discover by keyword, send targeted message"
def run(claude: anthropic.Anthropic, base_url: str, model: str) -> TestResult:
start = time.time()
agents_list: List[str] = ["disc_researcher", "disc_coder", "disc_analyst", "disc_translator"]
runs: List[AgentRun] = []
verifications: List[Dict[str, Any]] = []
try:
creds = register_agents(base_url, [
{"name": "disc_researcher", "display_name": "Researcher Agent",
"capabilities": {"research": True, "writing": True}},
{"name": "disc_coder", "display_name": "Coder Agent",
"capabilities": {"python": True, "golang": True, "devops": True}},
{"name": "disc_analyst", "display_name": "Analyst Agent",
"capabilities": {"data_analysis": True, "statistics": True}},
{"name": "disc_translator", "display_name": "Translator Agent",
"capabilities": {"translation": True, "languages": True}},
])
researcher_cred = creds[0]
analyst_cred = creds[2]
researcher_mcp = SynapBusMCP(base_url, researcher_cred.api_key)
analyst_mcp = SynapBusMCP(base_url, analyst_cred.api_key)
researcher_mcp.initialize()
analyst_mcp.initialize()
tools = get_tools_for_scenario(SCENARIO_NAME)
# Step 1: Researcher discovers agents with "analysis" capability
print("\n [disc] Researcher discovers agents...")
researcher_run = run_agent(
claude, researcher_mcp, "disc_researcher",
system_prompt=(
"You are a researcher agent on SynapBus. "
"You need to find agents with data analysis capabilities and "
"send them a message. Be concise and efficient."
),
user_prompt=(
"1. Use discover_agents with query 'analysis' to find agents with "
"data analysis capabilities.\n"
"2. Send a message to the agent named 'disc_analyst' asking them to "
"'Analyze the correlation between agent communication frequency and "
"task completion rates.' Use subject 'Analysis Request'."
),
tools=tools, model=model, max_tool_rounds=3,
)
runs.append(researcher_run)
# Verify discovery
discovered = any(tc.tool == "discover_agents" and tc.success
for tc in researcher_run.tool_calls)
verifications.append(make_verification("Researcher discovered agents", discovered))
# Check discovery returned the analyst
for tc in researcher_run.tool_calls:
if tc.tool == "discover_agents" and tc.success:
agents_found = tc.output.get("agents", [])
analyst_found = any(a.get("name") == "disc_analyst" for a in agents_found)
verifications.append(make_verification(
"Discovery found disc_analyst", analyst_found,
"{} agents returned".format(len(agents_found))))
break
else:
verifications.append(make_verification(
"Discovery found disc_analyst", False, "No discover_agents call"))
# Verify message sent
msg_sent = any(tc.tool == "send_message" and tc.success
for tc in researcher_run.tool_calls)
verifications.append(make_verification("Researcher sent message", msg_sent))
# Step 2: Verify analyst received the message
print(" [disc] Verifying analyst received message...")
analyst_inbox = analyst_mcp.call_tool("read_inbox", {"limit": 10})
msgs = analyst_inbox.get("messages", [])
researcher_msgs = [m for m in msgs if m.get("from_agent") == "disc_researcher"]
verifications.append(make_verification(
"Analyst received message from researcher",
len(researcher_msgs) > 0,
"{} messages from researcher".format(len(researcher_msgs)),
))
researcher_mcp.close()
analyst_mcp.close()
all_passed = all(v["passed"] for v in verifications)
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="pass" if all_passed else "fail",
agents=agents_list,
runs=runs,
verifications=verifications,
error=None,
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
except Exception as e:
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="error",
agents=agents_list,
runs=runs,
verifications=verifications,
error=traceback.format_exc(),
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
+231
View File
@@ -0,0 +1,231 @@
"""Scenario: Audit trail verification.
2 agents (audit_alice, audit_bob):
- Agents perform various actions (send messages, join channel, etc.)
- Query traces via REST API
- Verify all actions appear in traces with correct attribution
Uses httpx to query REST API directly after agents perform actions.
"""
from __future__ import annotations
import time
import traceback
from typing import Any, Dict, List
import anthropic
import httpx
from ..lib.agent_runner import AgentRun, TestResult, make_verification
from ..lib.mcp_client import SynapBusMCP
from ..lib.setup import register_agents, register_user
SCENARIO_NAME = "audit_trail"
DESCRIPTION = "Agents perform actions, then verify traces via REST API"
def run(claude: anthropic.Anthropic, base_url: str, model: str) -> TestResult:
start = time.time()
agents_list: List[str] = ["audit_alice", "audit_bob"]
runs: List[AgentRun] = []
verifications: List[Dict[str, Any]] = []
try:
creds = register_agents(base_url, [
{"name": "audit_alice", "display_name": "Alice (Audit)",
"capabilities": {"research": True}},
{"name": "audit_bob", "display_name": "Bob (Audit)",
"capabilities": {"analysis": True}},
])
alice_cred, bob_cred = creds
alice_mcp = SynapBusMCP(base_url, alice_cred.api_key)
bob_mcp = SynapBusMCP(base_url, bob_cred.api_key)
alice_mcp.initialize()
bob_mcp.initialize()
# Step 1: Perform some actions to generate traces
print("\n [audit] Generating actions...")
# Alice creates a channel
alice_mcp.call_tool("create_channel", {
"name": "audit-channel",
"description": "Channel for audit testing",
"type": "standard",
})
# Bob joins
bob_mcp.call_tool("join_channel", {"channel_name": "audit-channel"})
# Alice sends a direct message to Bob
alice_mcp.call_tool("send_message", {
"to": "audit_bob",
"body": "Hello Bob, this is an audit test message.",
"subject": "Audit Test",
})
# Alice sends a channel message
alice_mcp.call_tool("send_channel_message", {
"channel_name": "audit-channel",
"body": "Channel broadcast for audit test.",
})
# Bob reads inbox
bob_mcp.call_tool("read_inbox", {"limit": 10})
# Give traces a moment to be recorded
time.sleep(0.5)
# Step 2: Query traces via REST API
print(" [audit] Querying traces via REST API...")
# Get session cookies for REST API access
cookies = register_user(base_url)
http_client = httpx.Client(timeout=10, cookies=cookies)
try:
# Query all traces
resp = http_client.get("{}/api/traces".format(base_url))
verifications.append(make_verification(
"GET /api/traces returns 200",
resp.status_code == 200,
"status={}".format(resp.status_code),
))
if resp.status_code == 200:
traces_data = resp.json()
traces = traces_data.get("traces", [])
total = traces_data.get("total", 0)
verifications.append(make_verification(
"Traces returned",
total > 0,
"{} total traces".format(total),
))
# Check for Alice's actions
alice_traces = [t for t in traces
if t.get("agent_name") == "audit_alice"]
verifications.append(make_verification(
"Alice's actions in traces",
len(alice_traces) > 0,
"{} traces for audit_alice".format(len(alice_traces)),
))
# Check for specific action types
actions = [t.get("action") for t in traces]
verifications.append(make_verification(
"send_message action traced",
"send_message" in actions,
"actions found: {}".format(list(set(actions))[:10]),
))
# Query traces filtered by agent
resp_filtered = http_client.get(
"{}/api/traces".format(base_url),
params={"agent_name": "audit_alice"},
)
verifications.append(make_verification(
"Filtered traces by agent_name",
resp_filtered.status_code == 200,
"status={}".format(resp_filtered.status_code),
))
if resp_filtered.status_code == 200:
filtered_data = resp_filtered.json()
filtered_traces = filtered_data.get("traces", [])
all_alice = all(t.get("agent_name") == "audit_alice"
for t in filtered_traces)
verifications.append(make_verification(
"Filtered results only contain Alice",
all_alice and len(filtered_traces) > 0,
"{} traces, all Alice: {}".format(len(filtered_traces), all_alice),
))
# Query trace stats
print(" [audit] Querying trace stats...")
resp_stats = http_client.get("{}/api/traces/stats".format(base_url))
verifications.append(make_verification(
"GET /api/traces/stats returns 200",
resp_stats.status_code == 200,
"status={}".format(resp_stats.status_code),
))
if resp_stats.status_code == 200:
stats_data = resp_stats.json()
stats = stats_data.get("stats", {})
verifications.append(make_verification(
"Stats contain action counts",
len(stats) > 0,
"stats: {}".format(stats),
))
# Export traces as JSON
print(" [audit] Exporting traces...")
resp_export = http_client.get(
"{}/api/traces/export".format(base_url),
params={"format": "json"},
)
verifications.append(make_verification(
"GET /api/traces/export returns 200",
resp_export.status_code == 200,
"status={}, content-type={}".format(
resp_export.status_code,
resp_export.headers.get("content-type", "unknown")),
))
if resp_export.status_code == 200:
export_data = resp_export.json()
verifications.append(make_verification(
"Export contains traces",
isinstance(export_data, list) and len(export_data) > 0,
"{} exported traces".format(
len(export_data) if isinstance(export_data, list) else 0),
))
# Verify exported traces have expected fields
if isinstance(export_data, list) and len(export_data) > 0:
first_trace = export_data[0]
has_fields = all(
k in first_trace
for k in ["agent_name", "action", "timestamp"]
)
verifications.append(make_verification(
"Exported traces have expected fields",
has_fields,
"fields: {}".format(list(first_trace.keys())[:10]),
))
finally:
http_client.close()
alice_mcp.close()
bob_mcp.close()
all_passed = all(v["passed"] for v in verifications)
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="pass" if all_passed else "fail",
agents=agents_list,
runs=runs,
verifications=verifications,
error=None,
duration=time.time() - start,
total_input_tokens=0,
total_output_tokens=0,
)
except Exception as e:
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="error",
agents=agents_list,
runs=runs,
verifications=verifications,
error=traceback.format_exc(),
duration=time.time() - start,
total_input_tokens=0,
total_output_tokens=0,
)
+180
View File
@@ -0,0 +1,180 @@
"""Scenario: Blackboard channel stigmergy pattern.
3 agents (bb_scout, bb_analyst, bb_coordinator):
- Coordinator creates blackboard channel
- All three join
- Scout writes initial findings
- Analyst reads findings, writes analysis
- Coordinator reads all, writes summary
"""
from __future__ import annotations
import time
import traceback
from typing import Any, Dict, List
import anthropic
from ..lib.agent_runner import AgentRun, TestResult, ToolCall, make_verification, run_agent
from ..lib.mcp_client import SynapBusMCP
from ..lib.setup import register_agents
from ..lib.tools import get_tools_for_scenario
SCENARIO_NAME = "blackboard"
DESCRIPTION = "Blackboard channel stigmergy: scout writes findings, analyst writes analysis, coordinator summarizes"
def run(claude: anthropic.Anthropic, base_url: str, model: str) -> TestResult:
start = time.time()
agents_list: List[str] = ["bb_scout", "bb_analyst", "bb_coordinator"]
runs: List[AgentRun] = []
verifications: List[Dict[str, Any]] = []
try:
creds = register_agents(base_url, [
{"name": "bb_scout", "display_name": "Scout Agent",
"capabilities": {"reconnaissance": True, "observation": True}},
{"name": "bb_analyst", "display_name": "Analyst Agent",
"capabilities": {"analysis": True, "pattern_recognition": True}},
{"name": "bb_coordinator", "display_name": "Coordinator Agent",
"capabilities": {"coordination": True, "summarization": True}},
])
scout_cred, analyst_cred, coord_cred = creds
scout_mcp = SynapBusMCP(base_url, scout_cred.api_key)
analyst_mcp = SynapBusMCP(base_url, analyst_cred.api_key)
coord_mcp = SynapBusMCP(base_url, coord_cred.api_key)
scout_mcp.initialize()
analyst_mcp.initialize()
coord_mcp.initialize()
tools = get_tools_for_scenario(SCENARIO_NAME)
# Step 1: Coordinator creates blackboard channel
print("\n [bb] Coordinator creates blackboard channel...")
ch_result = coord_mcp.call_tool("create_channel", {
"name": "shared-findings",
"description": "Shared blackboard for collaborative findings",
"topic": "Current investigation",
"type": "blackboard",
})
ch_created = "channel_id" in ch_result or "name" in ch_result
verifications.append(make_verification(
"Blackboard channel created", ch_created,
str(ch_result)[:200]))
# Step 2: Scout and Analyst join
print(" [bb] Scout and Analyst joining...")
scout_join = scout_mcp.call_tool("join_channel", {"channel_name": "shared-findings"})
analyst_join = analyst_mcp.call_tool("join_channel", {"channel_name": "shared-findings"})
verifications.append(make_verification("Scout joined", scout_join.get("status") == "joined"))
verifications.append(make_verification("Analyst joined", analyst_join.get("status") == "joined"))
# Step 3: Scout writes initial findings via Claude
print("\n [bb] Scout writes initial findings...")
scout_run = run_agent(
claude, scout_mcp, "bb_scout",
system_prompt=(
"You are a scout agent on SynapBus. Write your field observations "
"to the shared blackboard channel. Be concise and factual."
),
user_prompt=(
"Post your findings to the 'shared-findings' channel using "
"send_channel_message. Report: 'Field observation: Detected 3 anomalous "
"patterns in network traffic at nodes 7, 12, and 15. Node 7 shows highest "
"deviation (4.2 sigma). Timestamps correlate with batch processing windows.'"
),
tools=tools, model=model, max_tool_rounds=2,
)
runs.append(scout_run)
scout_posted = any(tc.tool == "send_channel_message" and tc.success
for tc in scout_run.tool_calls)
verifications.append(make_verification("Scout posted findings", scout_posted))
# Step 4: Analyst reads and writes analysis via Claude
print("\n [bb] Analyst reads and writes analysis...")
analyst_run = run_agent(
claude, analyst_mcp, "bb_analyst",
system_prompt=(
"You are an analyst agent on SynapBus. Read the blackboard, "
"analyze the findings, and write your analysis back. Be concise."
),
user_prompt=(
"1. Read your inbox to see the scout's findings\n"
"2. Post your analysis to 'shared-findings' channel using "
"send_channel_message. Analyze the patterns mentioned and "
"suggest root causes."
),
tools=tools, model=model, max_tool_rounds=3,
)
runs.append(analyst_run)
analyst_posted = any(tc.tool == "send_channel_message" and tc.success
for tc in analyst_run.tool_calls)
verifications.append(make_verification("Analyst posted analysis", analyst_posted))
# Step 5: Coordinator reads all and writes summary via Claude
print("\n [bb] Coordinator reads and writes summary...")
coord_run = run_agent(
claude, coord_mcp, "bb_coordinator",
system_prompt=(
"You are the coordinator agent on SynapBus. Read all blackboard "
"entries, synthesize a summary, and post it. Be concise."
),
user_prompt=(
"1. Read your inbox to see all channel messages\n"
"2. Post a summary to 'shared-findings' using send_channel_message. "
"Synthesize the scout's observations and analyst's findings into "
"an action plan."
),
tools=tools, model=model, max_tool_rounds=3,
)
runs.append(coord_run)
coord_posted = any(tc.tool == "send_channel_message" and tc.success
for tc in coord_run.tool_calls)
verifications.append(make_verification("Coordinator posted summary", coord_posted))
# Step 6: Verify all messages are visible
print(" [bb] Verifying message visibility...")
scout_inbox = scout_mcp.call_tool("read_inbox", {"limit": 20, "include_read": True})
scout_msgs = scout_inbox.get("messages", [])
# Scout should see messages from analyst and coordinator
other_msgs = [m for m in scout_msgs
if m.get("from_agent") in ("bb_analyst", "bb_coordinator")]
verifications.append(make_verification(
"Scout sees other agents' messages",
len(other_msgs) >= 1,
"{} messages from other agents".format(len(other_msgs)),
))
scout_mcp.close()
analyst_mcp.close()
coord_mcp.close()
all_passed = all(v["passed"] for v in verifications)
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="pass" if all_passed else "fail",
agents=agents_list,
runs=runs,
verifications=verifications,
error=None,
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
except Exception as e:
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="error",
agents=agents_list,
runs=runs,
verifications=verifications,
error=traceback.format_exc(),
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
+188
View File
@@ -0,0 +1,188 @@
"""Scenario: Channel-based group communication.
3 agents (ch_alice, ch_bob, ch_carol):
- Alice creates "research-hub" standard channel
- Bob and Carol join
- Alice broadcasts a question
- Bob and Carol each read and reply on channel
- Alice reads channel messages
"""
from __future__ import annotations
import time
import traceback
from typing import Any, Dict, List
import anthropic
from ..lib.agent_runner import AgentRun, TestResult, ToolCall, make_verification, run_agent
from ..lib.mcp_client import SynapBusMCP
from ..lib.setup import register_agents
from ..lib.tools import get_tools_for_scenario
SCENARIO_NAME = "channels"
DESCRIPTION = "Three agents communicate via a shared channel: create, join, broadcast, reply"
def run(claude: anthropic.Anthropic, base_url: str, model: str) -> TestResult:
start = time.time()
agents_list: List[str] = ["ch_alice", "ch_bob", "ch_carol"]
runs: List[AgentRun] = []
verifications: List[Dict[str, Any]] = []
try:
creds = register_agents(base_url, [
{"name": "ch_alice", "display_name": "Alice (Channel Lead)",
"capabilities": {"coordination": True}},
{"name": "ch_bob", "display_name": "Bob (Channel Member)",
"capabilities": {"analysis": True}},
{"name": "ch_carol", "display_name": "Carol (Channel Member)",
"capabilities": {"writing": True}},
])
alice_cred, bob_cred, carol_cred = creds
alice_mcp = SynapBusMCP(base_url, alice_cred.api_key)
bob_mcp = SynapBusMCP(base_url, bob_cred.api_key)
carol_mcp = SynapBusMCP(base_url, carol_cred.api_key)
alice_mcp.initialize()
bob_mcp.initialize()
carol_mcp.initialize()
tools = get_tools_for_scenario(SCENARIO_NAME)
# Step 1: Alice creates channel (direct MCP call for reliability)
print("\n [ch] Alice creates channel...")
create_result = alice_mcp.call_tool("create_channel", {
"name": "research-hub",
"description": "Research collaboration channel",
"topic": "Current research topics",
"type": "standard",
})
channel_created = "channel_id" in create_result or "name" in create_result
verifications.append(make_verification(
"Channel 'research-hub' created", channel_created,
str(create_result)[:200]))
# Step 2: Bob joins
print(" [ch] Bob joins channel...")
bob_join = bob_mcp.call_tool("join_channel", {"channel_name": "research-hub"})
bob_joined = bob_join.get("status") == "joined"
verifications.append(make_verification("Bob joined channel", bob_joined))
# Step 3: Carol joins
print(" [ch] Carol joins channel...")
carol_join = carol_mcp.call_tool("join_channel", {"channel_name": "research-hub"})
carol_joined = carol_join.get("status") == "joined"
verifications.append(make_verification("Carol joined channel", carol_joined))
# Step 4: Alice broadcasts a question via Claude
print("\n [ch] Alice broadcasts question...")
alice_run = run_agent(
claude, alice_mcp, "ch_alice",
system_prompt=(
"You are Alice, coordinator of the research-hub channel on SynapBus. "
"Use the send_channel_message tool to broadcast to the channel. "
"Be concise."
),
user_prompt=(
"Send a message to channel 'research-hub' asking: "
"'Team, what are the most promising approaches to agent coordination? "
"Please share your perspective.' "
"Use the send_channel_message tool with channel_name='research-hub'."
),
tools=tools, model=model, max_tool_rounds=2,
)
runs.append(alice_run)
alice_broadcast = any(tc.tool == "send_channel_message" and tc.success
for tc in alice_run.tool_calls)
verifications.append(make_verification("Alice broadcast to channel", alice_broadcast))
# Step 5: Bob reads inbox and replies on channel
print("\n [ch] Bob reads and replies on channel...")
bob_run = run_agent(
claude, bob_mcp, "ch_bob",
system_prompt=(
"You are Bob, a member of the research-hub channel on SynapBus. "
"Read your inbox for channel messages and reply on the channel. "
"Be concise."
),
user_prompt=(
"1. Read your inbox to see channel messages\n"
"2. Reply on the channel 'research-hub' with your analysis perspective "
"using send_channel_message with channel_name='research-hub'"
),
tools=tools, model=model, max_tool_rounds=3,
)
runs.append(bob_run)
bob_replied = any(tc.tool == "send_channel_message" and tc.success
for tc in bob_run.tool_calls)
verifications.append(make_verification("Bob replied on channel", bob_replied))
# Step 6: Carol reads inbox and replies on channel
print("\n [ch] Carol reads and replies on channel...")
carol_run = run_agent(
claude, carol_mcp, "ch_carol",
system_prompt=(
"You are Carol, a member of the research-hub channel on SynapBus. "
"Read your inbox for channel messages and reply on the channel. "
"Be concise."
),
user_prompt=(
"1. Read your inbox to see channel messages\n"
"2. Reply on the channel 'research-hub' with your writing perspective "
"using send_channel_message with channel_name='research-hub'"
),
tools=tools, model=model, max_tool_rounds=3,
)
runs.append(carol_run)
carol_replied = any(tc.tool == "send_channel_message" and tc.success
for tc in carol_run.tool_calls)
verifications.append(make_verification("Carol replied on channel", carol_replied))
# Step 7: Verify Alice received channel messages
print(" [ch] Verifying Alice received messages...")
alice_inbox = alice_mcp.call_tool("read_inbox", {"limit": 20, "include_read": True})
msgs = alice_inbox.get("messages", [])
channel_msgs = [m for m in msgs
if m.get("from_agent") in ("ch_bob", "ch_carol")]
verifications.append(make_verification(
"Alice received channel replies",
len(channel_msgs) >= 1,
"{} channel messages received".format(len(channel_msgs)),
))
alice_mcp.close()
bob_mcp.close()
carol_mcp.close()
all_passed = all(v["passed"] for v in verifications)
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="pass" if all_passed else "fail",
agents=agents_list,
runs=runs,
verifications=verifications,
error=None,
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
except Exception as e:
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="error",
agents=agents_list,
runs=runs,
verifications=verifications,
error=traceback.format_exc(),
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
@@ -0,0 +1,151 @@
"""Scenario: Direct messaging between two agents.
2 agents (dm_alice, dm_bob):
- Alice discovers Bob
- Alice sends research question to Bob
- Bob reads inbox, claims message
- Bob sends reply
- Bob marks original done
- Alice reads reply
"""
from __future__ import annotations
import time
import traceback
from typing import Any, Dict, List
import anthropic
from ..lib.agent_runner import AgentRun, TestResult, ToolCall, make_verification, run_agent
from ..lib.mcp_client import SynapBusMCP
from ..lib.setup import register_agents
from ..lib.tools import get_tools_for_scenario
SCENARIO_NAME = "direct_messaging"
DESCRIPTION = "Two agents exchange direct messages: discover, send, read, claim, reply, mark done"
def run(claude: anthropic.Anthropic, base_url: str, model: str) -> TestResult:
start = time.time()
agents_list: List[str] = ["dm_alice", "dm_bob"]
runs: List[AgentRun] = []
verifications: List[Dict[str, Any]] = []
try:
# Register agents
creds = register_agents(base_url, [
{"name": "dm_alice", "display_name": "Alice the Researcher",
"capabilities": {"research": True, "summarization": True}},
{"name": "dm_bob", "display_name": "Bob the Analyst",
"capabilities": {"data_analysis": True, "coding": True}},
])
alice_cred, bob_cred = creds[0], creds[1]
# Initialize MCP sessions
alice_mcp = SynapBusMCP(base_url, alice_cred.api_key)
bob_mcp = SynapBusMCP(base_url, bob_cred.api_key)
alice_mcp.initialize()
bob_mcp.initialize()
tools = get_tools_for_scenario(SCENARIO_NAME)
# Step 1: Alice discovers and sends message
print("\n [dm] Alice discovers agents and sends message...")
alice_run = run_agent(
claude, alice_mcp, "dm_alice",
system_prompt=(
"You are Alice, a research agent on SynapBus. "
"You communicate with other agents using the provided tools. "
"Be concise. Complete your task in as few tool calls as possible."
),
user_prompt=(
"First, discover what other agents are available using discover_agents. "
"Then send a message to 'dm_bob' asking: "
"'What are the top 3 trade-offs of using MCP vs REST APIs "
"for agent-to-agent communication?' "
"Use subject 'MCP vs REST Analysis'."
),
tools=tools, model=model, max_tool_rounds=3,
)
runs.append(alice_run)
# Verify Alice sent a message
alice_sent = any(tc.tool == "send_message" and tc.success for tc in alice_run.tool_calls)
verifications.append(make_verification(
"Alice sent message to Bob", alice_sent,
"Found send_message tool call" if alice_sent else "No successful send_message call"))
# Step 2: Bob reads, claims, replies, marks done
print("\n [dm] Bob reads inbox and replies...")
bob_run = run_agent(
claude, bob_mcp, "dm_bob",
system_prompt=(
"You are Bob, a data analyst agent on SynapBus. "
"When you receive messages, process them and reply. "
"Be concise and direct. Complete your task efficiently."
),
user_prompt=(
"1. Check your inbox using read_inbox\n"
"2. Claim the message using claim_messages\n"
"3. Send a reply to 'dm_alice' answering her question about MCP vs REST\n"
"4. Mark the original message as done using mark_done with the message_id\n"
"Do all steps."
),
tools=tools, model=model, max_tool_rounds=5,
)
runs.append(bob_run)
# Verify Bob's actions
bob_read = any(tc.tool == "read_inbox" and tc.success for tc in bob_run.tool_calls)
bob_claimed = any(tc.tool == "claim_messages" and tc.success for tc in bob_run.tool_calls)
bob_replied = any(tc.tool == "send_message" and tc.success for tc in bob_run.tool_calls)
bob_marked = any(tc.tool == "mark_done" and tc.success for tc in bob_run.tool_calls)
verifications.append(make_verification("Bob read inbox", bob_read))
verifications.append(make_verification("Bob claimed message", bob_claimed))
verifications.append(make_verification("Bob sent reply", bob_replied))
verifications.append(make_verification("Bob marked done", bob_marked))
# Step 3: Verify Alice received the reply (direct MCP call, no Claude)
print("\n [dm] Verifying Alice received reply...")
alice_inbox = alice_mcp.call_tool("read_inbox", {"limit": 10})
msgs = alice_inbox.get("messages", [])
bob_replies = [m for m in msgs if m.get("from_agent") == "dm_bob"]
verifications.append(make_verification(
"Alice received reply from Bob",
len(bob_replies) > 0,
"{} reply(ies) found".format(len(bob_replies)),
))
alice_mcp.close()
bob_mcp.close()
all_passed = all(v["passed"] for v in verifications)
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="pass" if all_passed else "fail",
agents=agents_list,
runs=runs,
verifications=verifications,
error=None,
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
except Exception as e:
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="error",
agents=agents_list,
runs=runs,
verifications=verifications,
error=traceback.format_exc(),
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
+194
View File
@@ -0,0 +1,194 @@
"""Scenario: Task auction lifecycle.
3 agents (auction_poster, auction_bidder1, auction_bidder2):
- Poster creates auction channel
- Bidders join
- Poster posts a task
- Both bidders submit bids
- Poster accepts bidder1's bid
- Bidder1 completes the task
"""
from __future__ import annotations
import time
import traceback
from typing import Any, Dict, List
import anthropic
from ..lib.agent_runner import AgentRun, TestResult, ToolCall, make_verification, run_agent
from ..lib.mcp_client import SynapBusMCP
from ..lib.setup import register_agents
from ..lib.tools import get_tools_for_scenario
SCENARIO_NAME = "task_auction"
DESCRIPTION = "Task auction lifecycle: post task, bid, accept bid, complete task"
def run(claude: anthropic.Anthropic, base_url: str, model: str) -> TestResult:
start = time.time()
agents_list: List[str] = ["auction_poster", "auction_bidder1", "auction_bidder2"]
runs: List[AgentRun] = []
verifications: List[Dict[str, Any]] = []
try:
creds = register_agents(base_url, [
{"name": "auction_poster", "display_name": "Task Poster",
"capabilities": {"project_management": True}},
{"name": "auction_bidder1", "display_name": "Bidder One",
"capabilities": {"python": True, "ml": True}},
{"name": "auction_bidder2", "display_name": "Bidder Two",
"capabilities": {"golang": True, "devops": True}},
])
poster_cred, bidder1_cred, bidder2_cred = creds
poster_mcp = SynapBusMCP(base_url, poster_cred.api_key)
bidder1_mcp = SynapBusMCP(base_url, bidder1_cred.api_key)
bidder2_mcp = SynapBusMCP(base_url, bidder2_cred.api_key)
poster_mcp.initialize()
bidder1_mcp.initialize()
bidder2_mcp.initialize()
tools = get_tools_for_scenario(SCENARIO_NAME)
# Step 1: Create auction channel (direct MCP)
print("\n [auction] Creating auction channel...")
ch_result = poster_mcp.call_tool("create_channel", {
"name": "task-market",
"description": "Task marketplace",
"type": "auction",
})
ch_created = "channel_id" in ch_result or "name" in ch_result
verifications.append(make_verification("Auction channel created", ch_created,
str(ch_result)[:200]))
# Step 2: Bidders join
print(" [auction] Bidders joining channel...")
b1_join = bidder1_mcp.call_tool("join_channel", {"channel_name": "task-market"})
b2_join = bidder2_mcp.call_tool("join_channel", {"channel_name": "task-market"})
verifications.append(make_verification("Bidder1 joined", b1_join.get("status") == "joined"))
verifications.append(make_verification("Bidder2 joined", b2_join.get("status") == "joined"))
# Step 3: Poster posts a task (direct MCP)
print(" [auction] Posting task...")
task_result = poster_mcp.call_tool("post_task", {
"channel_name": "task-market",
"title": "Build ML Pipeline",
"description": "Build a data processing pipeline with feature engineering and model training",
"requirements": '{"skills": ["python", "ml"], "experience": "intermediate"}',
})
task_id = task_result.get("task_id")
verifications.append(make_verification(
"Task posted", task_id is not None,
"task_id={}".format(task_id)))
if task_id is None:
raise ValueError("Task creation failed: {}".format(task_result))
# Step 4: Bidder1 bids (via Claude for variety)
print("\n [auction] Bidder1 submitting bid...")
bidder1_run = run_agent(
claude, bidder1_mcp, "auction_bidder1",
system_prompt=(
"You are Bidder One, a Python/ML specialist on SynapBus. "
"Submit a bid on a task. Be concise."
),
user_prompt=(
"Submit a bid on task_id {task_id} using the bid_task tool. "
"Include your capabilities as JSON: '{{\"python\": true, \"ml\": true}}', "
"time_estimate '3 days', and message explaining why you are a good fit."
).format(task_id=task_id),
tools=tools, model=model, max_tool_rounds=2,
)
runs.append(bidder1_run)
bid1_submitted = any(tc.tool == "bid_task" and tc.success for tc in bidder1_run.tool_calls)
# Extract bid_id from tool output
bid1_id = None
for tc in bidder1_run.tool_calls:
if tc.tool == "bid_task" and tc.success:
bid1_id = tc.output.get("bid_id")
verifications.append(make_verification(
"Bidder1 submitted bid", bid1_submitted,
"bid_id={}".format(bid1_id)))
# Step 5: Bidder2 bids (direct MCP)
print(" [auction] Bidder2 submitting bid...")
bid2_result = bidder2_mcp.call_tool("bid_task", {
"task_id": task_id,
"capabilities": '{"golang": true, "devops": true}',
"time_estimate": "5 days",
"message": "I can handle the infrastructure side with Go",
})
bid2_id = bid2_result.get("bid_id")
verifications.append(make_verification(
"Bidder2 submitted bid", bid2_id is not None,
"bid_id={}".format(bid2_id)))
# Step 6: Poster accepts Bidder1's bid
if bid1_id is not None:
print(" [auction] Poster accepting Bidder1's bid...")
accept_result = poster_mcp.call_tool("accept_bid", {
"task_id": task_id,
"bid_id": bid1_id,
})
accepted = accept_result.get("status") == "accepted"
verifications.append(make_verification(
"Poster accepted Bidder1's bid", accepted,
str(accept_result)[:200]))
else:
verifications.append(make_verification(
"Poster accepted Bidder1's bid", False, "No bid_id available"))
# Step 7: Bidder1 completes task
print(" [auction] Bidder1 completing task...")
complete_result = bidder1_mcp.call_tool("complete_task", {"task_id": task_id})
completed = complete_result.get("status") == "completed"
verifications.append(make_verification(
"Bidder1 completed task", completed,
str(complete_result)[:200]))
# Step 8: Verify final task state
print(" [auction] Verifying task state...")
tasks = poster_mcp.call_tool("list_tasks", {
"channel_name": "task-market",
"status": "completed",
})
completed_tasks = tasks.get("tasks", [])
task_completed = any(t.get("id") == task_id for t in completed_tasks)
verifications.append(make_verification(
"Task shows as completed", task_completed,
"{} completed tasks found".format(len(completed_tasks))))
poster_mcp.close()
bidder1_mcp.close()
bidder2_mcp.close()
all_passed = all(v["passed"] for v in verifications)
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="pass" if all_passed else "fail",
agents=agents_list,
runs=runs,
verifications=verifications,
error=None,
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
except Exception as e:
return TestResult(
name=SCENARIO_NAME,
description=DESCRIPTION,
status="error",
agents=agents_list,
runs=runs,
verifications=verifications,
error=traceback.format_exc(),
duration=time.time() - start,
total_input_tokens=sum(r.input_tokens for r in runs),
total_output_tokens=sum(r.output_tokens for r in runs),
)
+114 -12
View File
@@ -2,44 +2,146 @@
@tailwind components;
@tailwind utilities;
:root {
--bg-primary: #1a1d21;
--bg-secondary: #222529;
--bg-tertiary: #2c2f33;
--bg-input: #383b40;
--text-primary: #e8e8e8;
--text-secondary: #9b9da0;
--text-link: #1d9bd1;
--accent-green: #2eb67d;
--accent-yellow: #ecb22e;
--accent-red: #e01e5a;
--accent-blue: #36c5f0;
--accent-purple: #7c3aed;
--border: #383b40;
--border-active: #545760;
}
@layer base {
body {
@apply bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100;
font-family: 'DM Sans', sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Instrument Sans', sans-serif;
}
code, pre, .font-mono {
font-family: 'JetBrains Mono', monospace;
}
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-active);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
}
@layer components {
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
@apply text-white px-4 py-2 rounded font-medium text-sm transition-all;
background-color: var(--accent-green);
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-primary:disabled {
@apply opacity-50 cursor-not-allowed;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 px-4 py-2 rounded-lg font-medium transition-colors;
@apply px-4 py-2 rounded font-medium text-sm transition-all;
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background-color: var(--border-active);
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50;
@apply text-white px-4 py-2 rounded font-medium text-sm transition-all;
background-color: var(--accent-red);
}
.btn-danger:hover {
filter: brightness(1.1);
}
.btn-danger:disabled {
@apply opacity-50;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-colors;
@apply w-full px-3 py-2 rounded text-sm outline-none transition-colors;
border: 1px solid var(--border);
background-color: var(--bg-input);
color: var(--text-primary);
}
.input::placeholder {
color: var(--text-secondary);
}
.input:focus {
border-color: var(--border-active);
box-shadow: 0 0 0 1px var(--border-active);
}
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
@apply rounded-lg;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
}
.badge-pending {
@apply badge bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
@apply badge;
background-color: rgba(236, 178, 46, 0.2);
color: var(--accent-yellow);
}
.badge-processing {
@apply badge bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
@apply badge;
background-color: rgba(54, 197, 240, 0.2);
color: var(--accent-blue);
}
.badge-done {
@apply badge bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
@apply badge;
background-color: rgba(46, 182, 125, 0.2);
color: var(--accent-green);
}
.badge-failed {
@apply badge bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
@apply badge;
background-color: rgba(224, 30, 90, 0.2);
color: var(--accent-red);
}
.skeleton {
@apply animate-pulse bg-gray-200 dark:bg-gray-700 rounded;
@apply animate-pulse rounded;
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;
color: var(--text-secondary);
}
.sidebar-item:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.sidebar-item-active {
color: var(--text-primary);
background-color: rgba(54, 197, 240, 0.1);
border-left: 2px solid var(--accent-blue);
}
.section-header {
@apply flex items-center justify-between px-3 py-1.5 text-xs font-semibold uppercase tracking-wider;
color: var(--text-secondary);
}
}
+3 -7
View File
@@ -5,13 +5,9 @@
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SynapBus</title>
<script>
// Prevent flash of wrong theme
const theme = localStorage.getItem('synapbus-theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
<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">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+9
View File
@@ -101,4 +101,13 @@ export const channels = {
request<{ status: string }>('POST', `/api/channels/${encodeURIComponent(name)}/leave`, agent ? { agent } : {})
};
// API Keys
export const apiKeys = {
list: () => request<{ keys: any[] }>('GET', '/api/keys'),
create: (body: { name: string; agent_id?: number; permissions?: object; allowed_channels?: string[]; read_only?: boolean; expires_at?: string }) =>
request<{ key: any; api_key: string; mcp_config: any }>('POST', '/api/keys', body),
revoke: (id: number) => request<{ status: string }>('DELETE', `/api/keys/${id}`),
get: (id: number) => request<any>('GET', `/api/keys/${id}`)
};
export { ApiError };
+14 -8
View File
@@ -11,19 +11,25 @@
let { agent }: { agent: Agent } = $props();
</script>
<a href="/agents/{agent.name}" class="card p-4 block hover:shadow-md transition-shadow">
<a href="/agents/{agent.name}" class="card p-4 block hover:border-border-active transition-all group">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900 dark:text-gray-100">{agent.display_name || agent.name}</h3>
{#if agent.display_name}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">@{agent.name}</p>
{/if}
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-bg-tertiary flex items-center justify-center text-sm font-bold text-text-secondary group-hover:text-text-primary transition-colors">
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
</div>
<div>
<h3 class="font-semibold text-sm text-text-primary">{agent.display_name || agent.name}</h3>
{#if agent.display_name}
<p class="text-xs text-text-secondary font-mono">@{agent.name}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<span class="badge {agent.type === 'ai' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300' : 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'}">
<span class="badge {agent.type === 'ai' ? 'bg-accent-purple/20 text-accent-purple' : 'bg-accent-blue/20 text-accent-blue'}">
{agent.type}
</span>
<span class="badge {agent.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'}">
<span class="flex items-center gap-1 badge {agent.status === 'active' ? 'bg-accent-green/20 text-accent-green' : 'bg-bg-tertiary text-text-secondary'}">
<span class="w-1.5 h-1.5 rounded-full {agent.status === 'active' ? 'bg-accent-green' : 'bg-text-secondary'}"></span>
{agent.status}
</span>
</div>
+155 -34
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { messages as messagesApi } from '$lib/api/client';
import { messages as messagesApi, agents as agentsApi, channels as channelsApi } from '$lib/api/client';
let { onSent = () => {} }: { onSent?: () => void } = $props();
@@ -7,12 +7,58 @@
let body = $state('');
let priority = $state(5);
let subject = $state('');
let channelId = $state<number | undefined>(undefined);
let sending = $state(false);
let error = $state('');
let showAdvanced = $state(false);
let showOptions = $state(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
// Autocomplete state
let agentList = $state<any[]>([]);
let channelList = $state<any[]>([]);
let showSuggestions = $state(false);
let filteredAgents = $derived(
to.trim()
? agentList.filter((a) =>
a.name.toLowerCase().includes(to.toLowerCase()) ||
(a.display_name && a.display_name.toLowerCase().includes(to.toLowerCase()))
).slice(0, 5)
: []
);
let _initialized = $state(false);
$effect(() => {
if (!_initialized) {
_initialized = true;
loadOptions();
}
});
async function loadOptions() {
try {
const [agRes, chRes] = await Promise.all([
agentsApi.list(),
channelsApi.list()
]);
agentList = agRes.agents ?? [];
channelList = chRes.channels ?? [];
} catch {
// handled
}
}
function selectAgent(name: string) {
to = name;
showSuggestions = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
async function handleSubmit() {
error = '';
if (!body.trim()) {
@@ -30,12 +76,14 @@
to: to.trim(),
body: body.trim(),
priority,
subject: subject.trim() || undefined
subject: subject.trim() || undefined,
channel_id: channelId
});
to = '';
body = '';
priority = 5;
subject = '';
channelId = undefined;
onSent();
} catch (err: any) {
error = err.message || 'Failed to send message';
@@ -43,42 +91,115 @@
sending = false;
}
}
const priorityLabels: Record<number, string> = {
1: 'Low',
5: 'Normal',
8: 'High',
10: 'Urgent'
};
</script>
<form class="card p-4" onsubmit={handleSubmit}>
<h3 class="font-medium text-gray-900 dark:text-gray-100 mb-3">New Message</h3>
<div class="bg-bg-secondary border border-border rounded-lg overflow-hidden">
{#if error}
<div class="mb-3 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
<div class="px-4 py-2 bg-accent-red/10 border-b border-accent-red/20 text-xs text-accent-red">
{error}
</div>
{/if}
<div class="space-y-3">
<input type="text" placeholder="Recipient agent name" class="input" bind:value={to} />
<textarea
placeholder="Message body"
class="input min-h-[80px] resize-y"
bind:value={body}
rows="3"
></textarea>
{#if showAdvanced}
<input type="text" placeholder="Subject (optional)" class="input" bind:value={subject} />
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Priority:</label>
<input type="range" min="1" max="10" class="flex-1" bind:value={priority} />
<span class="text-sm font-medium w-6 text-center">{priority}</span>
<!-- To field -->
<div class="relative border-b border-border">
<div class="flex items-center px-4 py-2 gap-2">
<span class="text-xs text-text-secondary font-medium flex-shrink-0">To:</span>
<input
type="text"
placeholder="Agent name..."
class="flex-1 bg-transparent text-sm text-text-primary placeholder-text-secondary outline-none"
bind:value={to}
onfocus={() => (showSuggestions = true)}
onblur={() => setTimeout(() => (showSuggestions = false), 200)}
/>
</div>
{#if showSuggestions && filteredAgents.length > 0}
<div class="absolute left-0 right-0 top-full z-10 bg-bg-secondary border border-border rounded-b-lg shadow-lg max-h-40 overflow-y-auto">
{#each filteredAgents as agent}
<button
class="w-full flex items-center gap-2 px-4 py-2 text-sm hover:bg-bg-tertiary transition-colors text-left"
onmousedown={() => selectAgent(agent.name)}
>
<span class="w-5 h-5 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
</span>
<span class="text-text-primary">{agent.display_name || agent.name}</span>
<span class="text-xs text-text-secondary font-mono">@{agent.name}</span>
</button>
{/each}
</div>
{/if}
<div class="flex items-center justify-between">
<button type="button" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" onclick={() => (showAdvanced = !showAdvanced)}>
{showAdvanced ? 'Hide' : 'Show'} options
</button>
<button type="submit" class="btn-primary" disabled={sending}>
{sending ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</form>
<!-- Message body -->
<textarea
placeholder="Write a message..."
class="w-full px-4 py-3 bg-transparent text-sm text-text-primary placeholder-text-secondary resize-none outline-none min-h-[80px]"
bind:value={body}
rows="3"
onkeydown={handleKeydown}
></textarea>
<!-- Options row -->
{#if showOptions}
<div class="px-4 py-2 border-t border-border space-y-2">
<input
type="text"
placeholder="Subject (optional)"
class="input text-xs"
bind:value={subject}
/>
{#if channelList.length > 0}
<div class="flex items-center gap-2">
<span class="text-xs text-text-secondary">Channel:</span>
<select class="input text-xs flex-1" bind:value={channelId}>
<option value={undefined}>None (DM)</option>
{#each channelList as ch}
<option value={ch.id}>#{ch.name}</option>
{/each}
</select>
</div>
{/if}
<div class="flex items-center gap-3">
<span class="text-xs text-text-secondary">Priority:</span>
<select class="input text-xs w-28" bind:value={priority}>
<option value={1}>Low (1)</option>
<option value={5}>Normal (5)</option>
<option value={8}>High (8)</option>
<option value={10}>Urgent (10)</option>
</select>
</div>
</div>
{/if}
<!-- Bottom bar -->
<div class="flex items-center justify-between px-3 py-2 border-t border-border">
<button
class="text-xs text-text-secondary hover:text-text-primary transition-colors px-2 py-1 rounded hover:bg-bg-tertiary"
onclick={() => (showOptions = !showOptions)}
>
{showOptions ? 'Hide options' : 'Options'}
</button>
<button
class="px-4 py-1.5 bg-accent-green rounded text-xs font-semibold text-white hover:brightness-110 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
disabled={sending || !body.trim() || !to.trim()}
onclick={handleSubmit}
>
{#if sending}
Sending...
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send
{/if}
</button>
</div>
</div>
+23 -67
View File
@@ -1,12 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth';
import { theme, toggleTheme } from '$lib/stores/theme';
let { onMenuClick }: { onMenuClick: () => void } = $props();
let searchQuery = $state('');
let showUserMenu = $state(false);
function handleSearch(e: SubmitEvent) {
e.preventDefault();
@@ -16,78 +12,38 @@
}
}
async function handleLogout() {
await logout();
goto('/login');
}
let pageTitle = $derived(() => {
const path = $page.url.pathname;
if (path === '/') return 'Dashboard';
if (path.startsWith('/conversations/')) return 'Thread';
if (path === '/conversations') return 'Conversations';
if (path.startsWith('/channels/')) return '#' + ($page.params.name ?? '');
if (path === '/channels') return 'Channels';
if (path.startsWith('/agents/')) return ($page.params.name ?? 'Agent');
if (path === '/agents') return 'Agents';
if (path === '/settings/api-keys') return 'API Keys';
if (path === '/settings') return 'Settings';
return 'SynapBus';
});
</script>
<header class="sticky top-0 z-20 flex items-center gap-4 h-16 px-4 lg:px-6 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<!-- Mobile menu button -->
<button class="lg:hidden p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" onclick={onMenuClick}>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<header class="flex items-center gap-4 h-12 px-5 border-b border-border bg-bg-primary flex-shrink-0">
<h2 class="font-display font-bold text-text-primary text-base">{pageTitle()}</h2>
<div class="flex-1"></div>
<!-- Search -->
<form class="flex-1 max-w-lg" onsubmit={handleSearch}>
<form class="max-w-xs w-full" onsubmit={handleSearch}>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search messages..."
class="w-full pl-10 pr-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 border-none rounded-lg focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="Search..."
class="w-full pl-8 pr-3 py-1.5 text-xs bg-bg-tertiary border border-border rounded text-text-primary placeholder-text-secondary focus:border-border-active focus:ring-0 outline-none"
bind:value={searchQuery}
/>
</div>
</form>
<div class="flex items-center gap-2">
<!-- Theme toggle -->
<button
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg"
onclick={toggleTheme}
title="Toggle dark mode"
>
{#if $theme === 'dark'}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{/if}
</button>
<!-- User menu -->
<div class="relative">
<button
class="flex items-center gap-2 p-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
onclick={() => (showUserMenu = !showUserMenu)}
>
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center text-primary-700 dark:text-primary-300 font-medium">
{$user?.username?.charAt(0).toUpperCase() ?? '?'}
</div>
<span class="hidden sm:inline">{$user?.display_name ?? $user?.username ?? ''}</span>
</button>
{#if showUserMenu}
<div class="absolute right-0 mt-2 w-48 card py-1 shadow-lg">
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" onclick={() => (showUserMenu = false)}>
Settings
</a>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={handleLogout}
>
Logout
</button>
</div>
{/if}
</div>
</div>
</header>
+90 -27
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { openThread } from '$lib/stores/thread';
type Message = {
id: number;
conversation_id: number;
@@ -8,17 +10,20 @@
priority: number;
status: string;
created_at: string;
reply_count?: number;
};
let { messages = [], showConversationLink = false }: { messages: Message[]; showConversationLink?: boolean } = $props();
let hoveredId = $state<number | null>(null);
function statusClass(status: string): string {
switch (status) {
case 'pending': return 'badge-pending';
case 'processing': return 'badge-processing';
case 'done': return 'badge-done';
case 'failed': return 'badge-failed';
default: return 'badge bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
default: return 'badge bg-bg-tertiary text-text-secondary';
}
}
@@ -28,54 +33,112 @@
const diff = now.getTime() - d.getTime();
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return d.toLocaleDateString();
if (diff < 86400000) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function truncate(s: string, len: number): string {
return s.length > len ? s.slice(0, len - 3) + '...' : s;
function agentColor(name: string): string {
const colors = ['bg-accent-blue', 'bg-accent-green', 'bg-accent-purple', 'bg-accent-yellow', 'bg-accent-red'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function handleClick(msg: Message) {
if (msg.conversation_id) {
openThread(msg.id, msg.conversation_id);
}
}
</script>
{#if messages.length === 0}
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
<div class="text-center py-16 text-text-secondary">
<svg class="w-10 h-10 mx-auto mb-3 text-border-active" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p>No messages yet</p>
<p class="text-sm">No messages yet</p>
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div>
{#each messages as msg (msg.id)}
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-start justify-between gap-3">
<div
class="group px-5 py-2.5 hover:bg-bg-tertiary/40 transition-colors cursor-pointer relative"
role="button"
tabindex="0"
onmouseenter={() => (hoveredId = msg.id)}
onmouseleave={() => (hoveredId = null)}
onclick={() => handleClick(msg)}
onkeydown={(e) => { if (e.key === 'Enter') handleClick(msg); }}
>
<div class="flex gap-3">
<!-- Avatar -->
<div class="w-9 h-9 rounded-lg {agentColor(msg.from_agent)} flex items-center justify-center text-sm font-bold text-white flex-shrink-0 mt-0.5">
{msg.from_agent.charAt(0).toUpperCase()}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm text-gray-900 dark:text-gray-100">{msg.from_agent}</span>
<div class="flex items-center gap-2 mb-0.5">
<span class="font-semibold text-sm text-text-primary hover:underline">{msg.from_agent}</span>
{#if msg.to_agent}
<svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
<svg class="w-3 h-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span class="text-sm text-gray-600 dark:text-gray-400">{msg.to_agent}</span>
<span class="text-sm text-text-secondary">{msg.to_agent}</span>
{/if}
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
<span class="{statusClass(msg.status)}">{msg.status}</span>
{#if msg.priority >= 8}
<span class="badge bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">P{msg.priority}</span>
<span class="badge bg-accent-red/20 text-accent-red">P{msg.priority}</span>
{:else if msg.priority >= 5}
<span class="badge bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300">P{msg.priority}</span>
<span class="badge bg-accent-yellow/20 text-accent-yellow">P{msg.priority}</span>
{/if}
</div>
<p class="text-sm text-gray-700 dark:text-gray-300">{truncate(msg.body, 200)}</p>
</div>
<div class="flex flex-col items-end gap-1 flex-shrink-0">
<span class="text-xs text-gray-500 dark:text-gray-400">{formatTime(msg.created_at)}</span>
{#if showConversationLink}
<a href="/conversations/{msg.conversation_id}" class="text-xs text-primary-600 hover:text-primary-700 dark:text-primary-400">
View thread
</a>
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body.length > 300 ? msg.body.slice(0, 300) + '...' : msg.body}</p>
<!-- Thread link -->
{#if showConversationLink && msg.conversation_id}
<button
class="mt-1 flex items-center gap-1 text-xs text-text-link hover:underline"
onclick={(e) => { e.stopPropagation(); openThread(msg.id, msg.conversation_id); }}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{msg.reply_count ? `${msg.reply_count} replies` : 'View thread'}
</button>
{/if}
</div>
</div>
<!-- Hover actions -->
{#if hoveredId === msg.id}
<div class="absolute top-1 right-4 flex items-center gap-0.5 bg-bg-secondary border border-border rounded-lg shadow-lg p-0.5">
<button
class="p-1.5 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
title="Reply in thread"
onclick={(e) => { e.stopPropagation(); openThread(msg.id, msg.conversation_id); }}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
{#if msg.status === 'pending'}
<button
class="p-1.5 rounded hover:bg-bg-tertiary text-text-secondary hover:text-accent-green transition-colors"
title="Mark done"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
{/if}
</div>
{/if}
</div>
{/each}
</div>
+236 -35
View File
@@ -1,47 +1,248 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth';
import { channels as channelsApi, agents as agentsApi } from '$lib/api/client';
let { open = $bindable(false) } = $props();
let channelList = $state<any[]>([]);
let agentList = $state<any[]>([]);
const links = [
{ href: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/conversations', label: 'Conversations', icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z' },
{ href: '/channels', label: 'Channels', icon: 'M7 20l4-16m2 16l4-16M6 9h14M4 15h14' },
{ href: '/agents', label: 'Agents', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ href: '/settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }
let channelsExpanded = $state(true);
let dmsExpanded = $state(true);
let adminExpanded = $state(true);
let _initialized = $state(false);
$effect(() => {
if (!_initialized && $user) {
_initialized = true;
loadSidebarData();
}
});
async function loadSidebarData() {
try {
const [chRes, agRes] = await Promise.all([
channelsApi.list(),
agentsApi.list()
]);
channelList = chRes.channels ?? [];
agentList = agRes.agents ?? [];
} catch {
// handled
}
}
function isActive(href: string): boolean {
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
}
async function handleLogout() {
await logout();
goto('/login');
}
const adminLinks = [
{ href: '/agents', label: 'Agents' },
{ href: '/settings', label: 'Settings' },
{ href: '/settings/api-keys', label: 'API Keys' }
];
</script>
<!-- Mobile overlay -->
{#if open}
<div class="fixed inset-0 z-30 bg-black/50 lg:hidden" onclick={() => (open = false)}></div>
{/if}
<aside
class="fixed top-0 left-0 z-40 h-screen w-64 transform bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transition-transform lg:translate-x-0 {open ? 'translate-x-0' : '-translate-x-full'}"
>
<div class="flex items-center gap-2 h-16 px-6 border-b border-gray-200 dark:border-gray-700">
<svg class="w-8 h-8 text-primary-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<span class="text-xl font-bold text-gray-900 dark:text-white">SynapBus</span>
<aside class="fixed top-0 left-0 z-40 h-screen w-[260px] bg-bg-secondary border-r border-border flex flex-col select-none">
<!-- Workspace header -->
<div class="flex items-center gap-2.5 h-14 px-4 border-b border-border flex-shrink-0">
<div class="w-7 h-7 rounded-lg bg-accent-purple flex items-center justify-center">
<svg class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h1 class="text-sm font-bold text-text-primary font-display truncate">SynapBus</h1>
<p class="text-[10px] text-text-secondary leading-tight">Mission Control</p>
</div>
<!-- User avatar -->
<button
class="w-7 h-7 rounded-full bg-accent-green/20 flex items-center justify-center text-accent-green text-xs font-bold hover:bg-accent-green/30 transition-colors"
onclick={handleLogout}
title="Logout"
>
{$user?.username?.charAt(0).toUpperCase() ?? '?'}
</button>
</div>
<nav class="px-3 py-4 space-y-1">
{#each links as link}
{@const active = $page.url.pathname === link.href || (link.href !== '/' && $page.url.pathname.startsWith(link.href))}
<a
href={link.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {active
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => (open = false)}
<!-- Search bar -->
<div class="px-3 py-2.5 flex-shrink-0">
<button
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-bg-tertiary rounded text-text-secondary text-xs hover:bg-bg-input transition-colors"
onclick={() => goto('/conversations')}
>
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Search messages
</button>
</div>
<!-- Scrollable nav -->
<nav class="flex-1 overflow-y-auto px-2 pb-3">
<!-- Dashboard -->
<a
href="/"
class="sidebar-item mb-1 {isActive('/') && $page.url.pathname === '/' ? 'sidebar-item-active' : ''}"
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a>
<!-- Conversations -->
<a
href="/conversations"
class="sidebar-item mb-3 {isActive('/conversations') ? 'sidebar-item-active' : ''}"
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Conversations
</a>
<!-- Channels section -->
<div class="mb-3">
<button
class="section-header w-full hover:text-text-primary transition-colors"
onclick={() => (channelsExpanded = !channelsExpanded)}
>
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d={link.icon} />
</svg>
{link.label}
</a>
{/each}
<span class="flex items-center gap-1">
<svg class="w-3 h-3 transition-transform {channelsExpanded ? 'rotate-0' : '-rotate-90'}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Channels
</span>
<a
href="/channels"
class="text-text-secondary hover:text-text-primary p-0.5"
onclick={(e) => e.stopPropagation()}
title="Manage channels"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</a>
</button>
{#if channelsExpanded}
<div class="mt-0.5">
{#if channelList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No channels</p>
{:else}
{#each channelList as ch}
<a
href="/channels/{ch.name}"
class="sidebar-item {isActive('/channels/' + ch.name) ? 'sidebar-item-active' : ''}"
>
<span class="text-text-secondary font-mono text-xs">#</span>
<span class="truncate">{ch.name}</span>
{#if ch.is_private}
<svg class="w-3 h-3 text-text-secondary ml-auto flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
{/if}
</a>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Direct Messages (Agents) section -->
<div class="mb-3">
<button
class="section-header w-full hover:text-text-primary transition-colors"
onclick={() => (dmsExpanded = !dmsExpanded)}
>
<span class="flex items-center gap-1">
<svg class="w-3 h-3 transition-transform {dmsExpanded ? 'rotate-0' : '-rotate-90'}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Direct Messages
</span>
</button>
{#if dmsExpanded}
<div class="mt-0.5">
{#if agentList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No agents</p>
{:else}
{#each agentList as agent}
<a
href="/agents/{agent.name}"
class="sidebar-item {isActive('/agents/' + agent.name) ? 'sidebar-item-active' : ''}"
>
<span class="relative flex-shrink-0">
<span class="w-5 h-5 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
</span>
<span
class="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-secondary {agent.status === 'active' ? 'bg-accent-green' : 'bg-text-secondary'}"
></span>
</span>
<span class="truncate">{agent.display_name || agent.name}</span>
{#if agent.type === 'ai'}
<span class="ml-auto text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded flex-shrink-0">AI</span>
{/if}
</a>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Admin section -->
<div class="mb-3">
<button
class="section-header w-full hover:text-text-primary transition-colors"
onclick={() => (adminExpanded = !adminExpanded)}
>
<span class="flex items-center gap-1">
<svg class="w-3 h-3 transition-transform {adminExpanded ? 'rotate-0' : '-rotate-90'}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Admin
</span>
</button>
{#if adminExpanded}
<div class="mt-0.5">
{#each adminLinks as link}
<a
href={link.href}
class="sidebar-item {isActive(link.href) ? 'sidebar-item-active' : ''}"
>
{#if link.label === 'Agents'}
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{:else if link.label === 'Settings'}
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{:else if link.label === 'API Keys'}
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{/if}
{link.label}
</a>
{/each}
</div>
{/if}
</div>
</nav>
<!-- Bottom status -->
<div class="px-3 py-2 border-t border-border flex-shrink-0">
<div class="flex items-center gap-2 text-xs text-text-secondary">
<span class="w-2 h-2 rounded-full bg-accent-green"></span>
<span class="truncate">{$user?.display_name ?? $user?.username ?? 'Connected'}</span>
</div>
</div>
</aside>
+171
View File
@@ -0,0 +1,171 @@
<script lang="ts">
import { activeThread, closeThread } from '$lib/stores/thread';
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
let conversation = $state<any>(null);
let threadMessages = $state<any[]>([]);
let loadingThread = $state(false);
let replyBody = $state('');
let sending = $state(false);
let error = $state('');
let currentThread = $state<{ messageId: number; conversationId: number } | null>(null);
activeThread.subscribe((val) => {
currentThread = val;
if (val) {
loadThread(val.conversationId);
} else {
conversation = null;
threadMessages = [];
}
});
async function loadThread(conversationId: number) {
loadingThread = true;
try {
const res = await convsApi.get(conversationId);
conversation = res.conversation;
threadMessages = res.messages;
} catch {
// handled
} finally {
loadingThread = false;
}
}
async function sendReply(e: SubmitEvent) {
e.preventDefault();
if (!replyBody.trim() || threadMessages.length === 0 || !currentThread) return;
sending = true;
error = '';
try {
const lastMsg = threadMessages[threadMessages.length - 1];
await messagesApi.send({
to: lastMsg.from_agent,
body: replyBody.trim(),
subject: conversation?.subject
});
replyBody = '';
await loadThread(currentThread.conversationId);
} catch (err: any) {
error = err.message || 'Failed to send reply';
} finally {
sending = false;
}
}
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return d.toLocaleDateString();
}
function statusClass(status: string): string {
switch (status) {
case 'pending': return 'badge-pending';
case 'processing': return 'badge-processing';
case 'done': return 'badge-done';
case 'failed': return 'badge-failed';
default: return 'badge bg-bg-tertiary text-text-secondary';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const form = (e.target as HTMLElement).closest('form');
if (form) form.requestSubmit();
}
}
</script>
{#if currentThread}
<div class="w-[400px] h-full border-l border-border bg-bg-secondary flex flex-col flex-shrink-0">
<!-- Thread header -->
<div class="flex items-center justify-between h-12 px-4 border-b border-border flex-shrink-0">
<div class="min-w-0">
<h3 class="font-display font-bold text-sm text-text-primary truncate">Thread</h3>
{#if conversation}
<p class="text-[10px] text-text-secondary truncate">{conversation.subject || 'Conversation #' + conversation.id}</p>
{/if}
</div>
<button
class="p-1.5 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
onclick={closeThread}
aria-label="Close thread"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Thread messages -->
<div class="flex-1 overflow-y-auto">
{#if loadingThread}
<div class="p-4 space-y-4">
{#each Array(3) as _}
<div class="flex gap-3">
<div class="skeleton w-8 h-8 rounded-full flex-shrink-0"></div>
<div class="flex-1 space-y-1.5">
<div class="skeleton h-3 w-1/3"></div>
<div class="skeleton h-3 w-2/3"></div>
</div>
</div>
{/each}
</div>
{:else}
{#each threadMessages as msg, i (msg.id)}
<div class="px-4 py-3 hover:bg-bg-tertiary/50 transition-colors {i === 0 ? 'border-b border-border bg-bg-primary/30' : ''}">
<div class="flex gap-2.5">
<div class="w-7 h-7 rounded-full bg-bg-tertiary flex items-center justify-center text-[11px] font-bold text-text-secondary flex-shrink-0">
{msg.from_agent.charAt(0).toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
<span class="font-semibold text-xs text-text-primary">{msg.from_agent}</span>
<span class="text-[10px] text-text-secondary">{formatTime(msg.created_at)}</span>
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
</div>
<p class="text-xs text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</p>
</div>
</div>
</div>
{/each}
{/if}
</div>
<!-- Reply input -->
<div class="border-t border-border p-3 flex-shrink-0">
{#if error}
<div class="mb-2 px-2 py-1 bg-accent-red/10 rounded text-[11px] text-accent-red">{error}</div>
{/if}
<form onsubmit={sendReply}>
<div class="bg-bg-input border border-border rounded-lg overflow-hidden focus-within:border-border-active">
<textarea
class="w-full px-3 py-2 bg-transparent text-xs text-text-primary placeholder-text-secondary resize-none outline-none"
placeholder="Reply..."
rows="2"
bind:value={replyBody}
onkeydown={handleKeydown}
></textarea>
<div class="flex items-center justify-end px-2 py-1.5">
<button
type="submit"
class="px-3 py-1 bg-accent-green rounded text-xs font-medium text-white hover:brightness-110 transition-all disabled:opacity-50"
disabled={sending || !replyBody.trim()}
>
{sending ? '...' : 'Send'}
</button>
</div>
</div>
</form>
</div>
</div>
{/if}
+10 -10
View File
@@ -21,32 +21,32 @@
</script>
{#if traces.length === 0}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No activity traces</p>
<div class="text-center py-8 text-text-secondary">
<p class="text-sm">No activity traces</p>
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div class="divide-y divide-border">
{#each traces as trace (trace.id)}
<div class="py-2">
<div>
<button
class="w-full flex items-center justify-between text-left px-3 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50"
class="w-full flex items-center justify-between text-left px-4 py-2.5 hover:bg-bg-tertiary/50 transition-colors"
onclick={() => toggle(trace.id)}
>
<div class="flex items-center gap-3">
<span class="badge bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 font-mono text-xs">
<span class="badge bg-bg-tertiary text-text-secondary font-mono text-[11px]">
{trace.action}
</span>
{#if trace.error}
<span class="badge-failed">error</span>
{/if}
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">{formatTime(trace.timestamp)}</span>
<span class="text-[11px] text-text-secondary">{formatTime(trace.timestamp)}</span>
</button>
{#if expandedId === trace.id}
<div class="mx-3 mt-1 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<pre class="text-xs text-gray-700 dark:text-gray-300 overflow-x-auto whitespace-pre-wrap">{JSON.stringify(trace.details, null, 2)}</pre>
<div class="mx-4 mb-3 p-3 bg-bg-primary rounded-lg border border-border">
<pre class="text-xs text-text-primary/80 overflow-x-auto whitespace-pre-wrap font-mono">{JSON.stringify(trace.details, null, 2)}</pre>
{#if trace.error}
<p class="mt-2 text-xs text-red-600 dark:text-red-400">Error: {trace.error}</p>
<p class="mt-2 text-xs text-accent-red">Error: {trace.error}</p>
{/if}
</div>
{/if}
+11
View File
@@ -0,0 +1,11 @@
import { writable } from 'svelte/store';
export const activeThread = writable<{ messageId: number; conversationId: number } | null>(null);
export function openThread(messageId: number, conversationId: number) {
activeThread.set({ messageId, conversationId });
}
export function closeThread() {
activeThread.set(null);
}
+12 -8
View File
@@ -6,9 +6,9 @@
import { SSEClient } from '$lib/api/sse';
import Sidebar from '$lib/components/Sidebar.svelte';
import Header from '$lib/components/Header.svelte';
import ThreadPanel from '$lib/components/ThreadPanel.svelte';
let { children } = $props();
let sidebarOpen = $state(false);
let sseClient: SSEClient | null = $state(null);
let initialized = $state(false);
@@ -42,19 +42,23 @@
</script>
{#if $loading}
<div class="flex items-center justify-center min-h-screen">
<div class="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin"></div>
<div class="flex items-center justify-center min-h-screen bg-bg-primary">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-2 border-border-active border-t-accent-blue rounded-full animate-spin"></div>
<span class="text-xs text-text-secondary">Loading...</span>
</div>
</div>
{:else if isLoginPage}
{@render children()}
{:else if $user}
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<Sidebar bind:open={sidebarOpen} />
<div class="lg:ml-64">
<Header onMenuClick={() => (sidebarOpen = !sidebarOpen)} />
<main class="p-4 lg:p-6">
<div class="h-screen flex overflow-hidden bg-bg-primary">
<Sidebar />
<div class="ml-[260px] flex-1 flex flex-col min-w-0">
<Header />
<main class="flex-1 overflow-y-auto">
{@render children()}
</main>
</div>
<ThreadPanel />
</div>
{/if}
+54 -32
View File
@@ -36,64 +36,78 @@
});
</script>
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<button class="btn-primary" onclick={() => (showCompose = !showCompose)}>
{showCompose ? 'Cancel' : 'New Message'}
<div class="p-5 max-w-5xl">
<!-- Top bar -->
<div class="flex items-center justify-between mb-5">
<div>
<h1 class="text-xl font-bold text-text-primary font-display">Dashboard</h1>
<p class="text-xs text-text-secondary mt-0.5">Mission control overview</p>
</div>
<button
class="btn-primary flex items-center gap-1.5"
onclick={() => (showCompose = !showCompose)}
>
{#if showCompose}
Cancel
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
New Message
{/if}
</button>
</div>
{#if showCompose}
<div class="mb-6">
<div class="mb-5">
<ComposeForm onSent={() => { showCompose = false; loadData(); }} />
</div>
{/if}
<!-- Stats -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="grid grid-cols-3 gap-3 mb-5">
<div class="card p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Messages</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{loadingData ? '-' : recentMessages.length}</p>
<p class="text-xs text-text-secondary mb-1">Messages</p>
<p class="text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : recentMessages.length}</p>
</div>
<div class="card p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Conversations</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{loadingData ? '-' : recentConversations.length}</p>
<p class="text-xs text-text-secondary mb-1">Conversations</p>
<p class="text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : recentConversations.length}</p>
</div>
<div class="card p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Agents</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{loadingData ? '-' : agentCount}</p>
<p class="text-xs text-text-secondary mb-1">Agents</p>
<p class="text-2xl font-bold text-text-primary font-display">{loadingData ? '-' : agentCount}</p>
</div>
</div>
<!-- Recent Conversations -->
{#if recentConversations.length > 0}
<div class="card mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Recent Conversations</h2>
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Recent Conversations</h2>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div class="divide-y divide-border">
{#each recentConversations.slice(0, 5) as conv}
<a href="/conversations/{conv.id}" class="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<a href="/conversations/{conv.id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
<p class="font-medium text-sm text-text-primary truncate">
{conv.subject || 'Untitled conversation'}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
<p class="text-xs text-text-secondary truncate mt-0.5">
{conv.last_agent}: {conv.last_message}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
<span class="badge bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">{conv.message_count} msgs</span>
<span class="badge bg-bg-tertiary text-text-secondary">{conv.message_count} msgs</span>
</div>
</div>
</a>
{/each}
</div>
{#if recentConversations.length > 5}
<div class="px-4 py-2 border-t border-gray-200 dark:border-gray-700">
<a href="/conversations" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400">View all conversations</a>
<div class="px-5 py-2.5 border-t border-border">
<a href="/conversations" class="text-xs text-text-link hover:underline">View all conversations</a>
</div>
{/if}
</div>
@@ -101,15 +115,18 @@
<!-- Recent Messages -->
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Recent Messages</h2>
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Recent Messages</h2>
</div>
{#if loadingData}
<div class="p-4 space-y-3">
<div class="p-5 space-y-4">
{#each Array(3) as _}
<div class="space-y-2">
<div class="skeleton h-4 w-1/3"></div>
<div class="skeleton h-3 w-2/3"></div>
<div class="flex gap-3">
<div class="skeleton w-9 h-9 rounded-lg flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="skeleton h-3 w-1/3"></div>
<div class="skeleton h-3 w-2/3"></div>
</div>
</div>
{/each}
</div>
@@ -119,9 +136,14 @@
</div>
{#if !loadingData && recentMessages.length === 0 && agentCount === 0}
<div class="card p-8 text-center mt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Welcome to SynapBus</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">No conversations yet. Register an agent to get started.</p>
<div class="card p-10 text-center mt-5">
<div class="w-12 h-12 mx-auto rounded-2xl bg-accent-purple/20 flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<h3 class="text-base font-semibold text-text-primary font-display mb-2">Welcome to SynapBus</h3>
<p class="text-sm text-text-secondary mb-4">No conversations yet. Register an agent to get started.</p>
<a href="/agents" class="btn-primary inline-block">Register an Agent</a>
</div>
{/if}
+29 -18
View File
@@ -6,7 +6,6 @@
let loadingData = $state(true);
let showRegister = $state(false);
// Register form
let newName = $state('');
let newDisplayName = $state('');
let newType = $state('ai');
@@ -62,24 +61,31 @@
}
</script>
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Agents</h1>
<button class="btn-primary" onclick={() => { showRegister = !showRegister; newApiKey = ''; }}>
{showRegister ? 'Cancel' : 'Register Agent'}
<div class="p-5 max-w-5xl">
<div class="flex items-center justify-between mb-5">
<h1 class="text-xl font-bold text-text-primary font-display">Agents</h1>
<button class="btn-primary flex items-center gap-1.5" onclick={() => { showRegister = !showRegister; newApiKey = ''; }}>
{#if showRegister}
Cancel
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Register Agent
{/if}
</button>
</div>
{#if showRegister}
<form class="card p-4 mb-6" onsubmit={handleRegister}>
<h3 class="font-medium text-gray-900 dark:text-gray-100 mb-3">Register New Agent</h3>
<form class="card p-5 mb-5" onsubmit={handleRegister}>
<h3 class="font-semibold text-sm text-text-primary font-display mb-3">Register New Agent</h3>
{#if registerError}
<div class="mb-3 p-2 bg-red-50 dark:bg-red-900/30 rounded text-sm text-red-700 dark:text-red-300">{registerError}</div>
<div class="mb-3 px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{registerError}</div>
{/if}
{#if newApiKey}
<div class="mb-3 p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-sm font-medium text-green-800 dark:text-green-200 mb-1">Agent registered! Save this API key - it will not be shown again:</p>
<code class="block p-2 bg-white dark:bg-gray-800 rounded text-xs font-mono break-all select-all">{newApiKey}</code>
<div class="mb-4 p-4 bg-accent-green/10 border border-accent-green/20 rounded-lg">
<p class="text-xs font-semibold text-accent-green mb-2">Agent registered! Save this API key - it will not be shown again:</p>
<code class="block p-3 bg-bg-primary rounded text-xs font-mono text-text-primary break-all select-all border border-border">{newApiKey}</code>
</div>
{/if}
<div class="space-y-3">
@@ -97,20 +103,25 @@
{/if}
{#if loadingData}
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-3 sm:grid-cols-2">
{#each Array(4) as _}
<div class="card p-4">
<div class="skeleton h-5 w-1/3 mb-2"></div>
<div class="skeleton h-4 w-1/2"></div>
<div class="flex gap-3">
<div class="skeleton w-10 h-10 rounded-lg"></div>
<div class="flex-1 space-y-2">
<div class="skeleton h-4 w-1/3"></div>
<div class="skeleton h-3 w-1/2"></div>
</div>
</div>
</div>
{/each}
</div>
{:else if agentList.length === 0}
<div class="card p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No agents registered yet.</p>
<div class="card p-8 text-center text-text-secondary text-sm">
No agents registered yet.
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-3 sm:grid-cols-2">
{#each agentList as agent (agent.id)}
<AgentCard {agent} />
{/each}
+47 -34
View File
@@ -63,81 +63,94 @@
}
</script>
<div class="max-w-4xl mx-auto">
<a href="/agents" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 mb-4 inline-block">
&larr; Back to agents
<div class="p-5 max-w-5xl">
<a href="/agents" class="inline-flex items-center gap-1 text-xs text-text-link hover:underline mb-4">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to agents
</a>
{#if loadingData}
<div class="card p-6">
<div class="skeleton h-8 w-1/3 mb-4"></div>
<div class="skeleton h-4 w-1/2 mb-2"></div>
<div class="skeleton h-4 w-1/4"></div>
<div class="flex gap-3">
<div class="skeleton w-12 h-12 rounded-lg"></div>
<div class="flex-1 space-y-2">
<div class="skeleton h-5 w-1/3"></div>
<div class="skeleton h-3 w-1/4"></div>
</div>
</div>
</div>
{:else if agent}
<!-- Agent Details -->
<div class="card mb-6">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="card mb-5">
<div class="p-5 border-b border-border">
<div class="flex items-start justify-between">
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{agent.display_name || agent.name}</h1>
{#if agent.display_name}
<p class="text-sm text-gray-500 dark:text-gray-400">@{agent.name}</p>
{/if}
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-bg-tertiary flex items-center justify-center text-lg font-bold text-text-secondary">
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
</div>
<div>
<h1 class="text-lg font-bold text-text-primary font-display">{agent.display_name || agent.name}</h1>
{#if agent.display_name}
<p class="text-xs text-text-secondary font-mono">@{agent.name}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<span class="badge {agent.type === 'ai' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300' : 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'}">
<span class="badge {agent.type === 'ai' ? 'bg-accent-purple/20 text-accent-purple' : 'bg-accent-blue/20 text-accent-blue'}">
{agent.type}
</span>
<span class="badge {agent.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-600'}">
<span class="flex items-center gap-1.5 badge {agent.status === 'active' ? 'bg-accent-green/20 text-accent-green' : 'bg-bg-tertiary text-text-secondary'}">
<span class="w-1.5 h-1.5 rounded-full {agent.status === 'active' ? 'bg-accent-green' : 'bg-text-secondary'}"></span>
{agent.status}
</span>
</div>
</div>
</div>
<div class="p-4 space-y-3">
<div class="p-5 space-y-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400">Created</p>
<p class="text-gray-900 dark:text-gray-100">{new Date(agent.created_at).toLocaleString()}</p>
<p class="text-xs text-text-secondary mb-0.5">Created</p>
<p class="text-text-primary">{new Date(agent.created_at).toLocaleString()}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Last Updated</p>
<p class="text-gray-900 dark:text-gray-100">{new Date(agent.updated_at).toLocaleString()}</p>
<p class="text-xs text-text-secondary mb-0.5">Last Updated</p>
<p class="text-text-primary">{new Date(agent.updated_at).toLocaleString()}</p>
</div>
</div>
{#if agent.capabilities && JSON.stringify(agent.capabilities) !== '{}'}
<div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Capabilities</p>
<pre class="text-xs bg-gray-50 dark:bg-gray-900 p-2 rounded overflow-x-auto">{JSON.stringify(agent.capabilities, null, 2)}</pre>
<p class="text-xs text-text-secondary mb-1">Capabilities</p>
<pre class="text-xs bg-bg-primary p-3 rounded-lg border border-border overflow-x-auto font-mono text-text-primary/80">{JSON.stringify(agent.capabilities, null, 2)}</pre>
</div>
{/if}
</div>
<!-- Actions -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<div class="p-5 border-t border-border space-y-3">
{#if revokeKey}
<div class="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-sm font-medium text-green-800 dark:text-green-200 mb-1">New API key generated. Save it now - it will not be shown again:</p>
<code class="block p-2 bg-white dark:bg-gray-800 rounded text-xs font-mono break-all select-all">{revokeKey}</code>
<div class="p-4 bg-accent-green/10 border border-accent-green/20 rounded-lg">
<p class="text-xs font-semibold text-accent-green mb-2">New API key generated. Save it now - it will not be shown again:</p>
<code class="block p-3 bg-bg-primary rounded text-xs font-mono text-text-primary break-all select-all border border-border">{revokeKey}</code>
</div>
{/if}
<div class="flex gap-2">
<button class="btn-secondary text-sm" onclick={handleRevokeKey} disabled={revoking}>
<button class="btn-secondary text-xs" onclick={handleRevokeKey} disabled={revoking}>
{revoking ? 'Regenerating...' : 'Regenerate API Key'}
</button>
{#if !confirmDelete}
<button class="btn-danger text-sm" onclick={() => (confirmDelete = true)}>
<button class="btn-danger text-xs" onclick={() => (confirmDelete = true)}>
Delete Agent
</button>
{:else}
<button class="btn-danger text-sm" onclick={handleDelete} disabled={deleting}>
<button class="btn-danger text-xs" onclick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button class="btn-secondary text-sm" onclick={() => (confirmDelete = false)}>Cancel</button>
<button class="btn-secondary text-xs" onclick={() => (confirmDelete = false)}>Cancel</button>
{/if}
</div>
</div>
@@ -145,14 +158,14 @@
<!-- Activity Traces -->
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Activity Traces</h2>
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Activity Traces</h2>
</div>
<TraceViewer {traces} />
</div>
{:else}
<div class="card p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">Agent not found</p>
<div class="card p-8 text-center text-text-secondary text-sm">
Agent not found
</div>
{/if}
</div>
+25 -19
View File
@@ -5,7 +5,6 @@
let loadingData = $state(true);
let showCreate = $state(false);
// Create form
let newName = $state('');
let newDescription = $state('');
let newIsPrivate = $state(false);
@@ -59,24 +58,31 @@
}
</script>
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Channels</h1>
<button class="btn-primary" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Cancel' : 'Create Channel'}
<div class="p-5 max-w-5xl">
<div class="flex items-center justify-between mb-5">
<h1 class="text-xl font-bold text-text-primary font-display">Channels</h1>
<button class="btn-primary flex items-center gap-1.5" onclick={() => (showCreate = !showCreate)}>
{#if showCreate}
Cancel
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create Channel
{/if}
</button>
</div>
{#if showCreate}
<form class="card p-4 mb-6" onsubmit={handleCreate}>
<form class="card p-5 mb-5" onsubmit={handleCreate}>
{#if createError}
<div class="mb-3 p-2 bg-red-50 dark:bg-red-900/30 rounded text-sm text-red-700 dark:text-red-300">{createError}</div>
<div class="mb-3 px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{createError}</div>
{/if}
<div class="space-y-3">
<input type="text" class="input" placeholder="Channel name (e.g. research-findings)" bind:value={newName} />
<input type="text" class="input" placeholder="Description (optional)" bind:value={newDescription} />
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" bind:checked={newIsPrivate} class="rounded" />
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input type="checkbox" bind:checked={newIsPrivate} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Private channel (invite-only)
</label>
<button type="submit" class="btn-primary" disabled={creating}>
@@ -88,34 +94,34 @@
<div class="card">
{#if loadingData}
<div class="p-4 space-y-3">
<div class="p-5 space-y-3">
{#each Array(3) as _}
<div class="skeleton h-12 w-full"></div>
{/each}
</div>
{:else if channelList.length === 0}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<p>No channels yet. Create one to get started.</p>
<div class="p-8 text-center text-text-secondary text-sm">
No channels yet. Create one to get started.
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div class="divide-y divide-border">
{#each channelList as ch}
<a href="/channels/{ch.name}" class="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<a href="/channels/{ch.name}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm text-gray-900 dark:text-gray-100">#{ch.name}</span>
<span class="font-medium text-sm text-text-primary font-mono">#{ch.name}</span>
{#if ch.is_private}
<svg class="w-3.5 h-3.5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<svg class="w-3.5 h-3.5 text-text-secondary" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
{/if}
</div>
{#if ch.description}
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">{ch.description}</p>
<p class="text-xs text-text-secondary truncate mt-0.5">{ch.description}</p>
{/if}
</div>
<span class="badge bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 flex-shrink-0 ml-3">
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
{ch.member_count} members
</span>
</div>
+30 -22
View File
@@ -58,9 +58,12 @@
}
</script>
<div class="max-w-4xl mx-auto">
<a href="/channels" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 mb-4 inline-block">
&larr; Back to channels
<div class="p-5 max-w-5xl">
<a href="/channels" class="inline-flex items-center gap-1 text-xs text-text-link hover:underline mb-4">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to channels
</a>
{#if loadingData}
@@ -70,52 +73,57 @@
<div class="skeleton h-20 w-full"></div>
</div>
{:else if channel}
<div class="card mb-6">
<div class="px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="card mb-5">
<div class="px-5 py-4 border-b border-border">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">#{channel.name}</h1>
<h1 class="text-lg font-bold text-text-primary font-display font-mono">#{channel.name}</h1>
{#if channel.is_private}
<span class="badge bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">Private</span>
<span class="badge bg-bg-tertiary text-text-secondary">Private</span>
{/if}
<span class="badge bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">{channel.type}</span>
<span class="badge bg-accent-blue/20 text-accent-blue">{channel.type}</span>
</div>
{#if channel.description}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{channel.description}</p>
<p class="text-sm text-text-secondary mt-1">{channel.description}</p>
{/if}
{#if channel.topic}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Topic: {channel.topic}</p>
<p class="text-xs text-text-secondary mt-1">Topic: {channel.topic}</p>
{/if}
</div>
<div class="flex gap-2">
<button class="btn-primary text-sm" onclick={handleJoin} disabled={joining}>
<button class="btn-primary text-xs" onclick={handleJoin} disabled={joining}>
{joining ? '...' : 'Join'}
</button>
<button class="btn-secondary text-sm" onclick={handleLeave} disabled={joining}>
<button class="btn-secondary text-xs" onclick={handleLeave} disabled={joining}>
Leave
</button>
</div>
</div>
{#if joinError}
<div class="mt-2 text-sm text-red-600 dark:text-red-400">{joinError}</div>
<div class="mt-2 text-xs text-accent-red">{joinError}</div>
{/if}
</div>
</div>
<!-- Members -->
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Members ({members.length})</h2>
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Members ({members.length})</h2>
</div>
{#if members.length === 0}
<div class="p-6 text-center text-gray-500 dark:text-gray-400">No members</div>
<div class="p-6 text-center text-text-secondary text-sm">No members</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div class="divide-y divide-border">
{#each members as member}
<div class="px-4 py-2 flex items-center justify-between">
<span class="text-sm text-gray-900 dark:text-gray-100">{member.agent_name}</span>
<span class="badge {member.role === 'owner' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'}">
<div class="px-5 py-2.5 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
{member.agent_name.charAt(0).toUpperCase()}
</span>
<span class="text-sm text-text-primary">{member.agent_name}</span>
</div>
<span class="badge {member.role === 'owner' ? 'bg-accent-yellow/20 text-accent-yellow' : 'bg-bg-tertiary text-text-secondary'}">
{member.role}
</span>
</div>
@@ -124,8 +132,8 @@
{/if}
</div>
{:else}
<div class="card p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">Channel not found</p>
<div class="card p-8 text-center text-text-secondary text-sm">
Channel not found
</div>
{/if}
</div>
+22 -22
View File
@@ -34,29 +34,29 @@
});
</script>
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Conversations</h1>
<div class="p-5 max-w-5xl">
<h1 class="text-xl font-bold text-text-primary font-display mb-5">Conversations</h1>
{#if searchResults !== null}
<div class="card mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">
Search Results ({searchResults.length})
</h2>
</div>
{#if searchResults.length === 0}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<div class="p-8 text-center text-text-secondary text-sm">
No messages found for '{$page.url.searchParams.get('q')}'
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div class="divide-y divide-border">
{#each searchResults as msg}
<a href="/conversations/{msg.conversation_id}" class="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<a href="/conversations/{msg.conversation_id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm">{msg.from_agent}</span>
<span class="text-xs text-gray-500">{new Date(msg.created_at).toLocaleString()}</span>
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
<span class="text-xs text-text-secondary">{new Date(msg.created_at).toLocaleString()}</span>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300">{msg.body.slice(0, 200)}</p>
<p class="text-sm text-text-primary/80">{msg.body.slice(0, 200)}</p>
</a>
{/each}
</div>
@@ -65,36 +65,36 @@
{/if}
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">All Conversations</h2>
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">All Conversations</h2>
</div>
{#if loadingData}
<div class="p-4 space-y-3">
<div class="p-5 space-y-4">
{#each Array(5) as _}
<div class="space-y-2">
<div class="skeleton h-4 w-1/3"></div>
<div class="skeleton h-3 w-1/3"></div>
<div class="skeleton h-3 w-2/3"></div>
</div>
{/each}
</div>
{:else if convList.length === 0}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<p>No conversations yet.</p>
<div class="p-8 text-center text-text-secondary text-sm">
No conversations yet.
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div class="divide-y divide-border">
{#each convList as conv}
<a href="/conversations/{conv.id}" class="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<a href="/conversations/{conv.id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
<p class="font-medium text-sm text-text-primary truncate">
{conv.subject || 'Untitled'}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
<p class="text-xs text-text-secondary truncate mt-0.5">
{conv.last_agent}: {conv.last_message}
</p>
</div>
<span class="badge bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 flex-shrink-0 ml-3">
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
{conv.message_count}
</span>
</div>
+117 -55
View File
@@ -39,7 +39,6 @@
sending = true;
error = '';
try {
// Reply to the most recent sender
const lastMsg = messagesList[messagesList.length - 1];
const to = lastMsg.from_agent;
await messagesApi.send({
@@ -62,74 +61,137 @@
case 'processing': return 'badge-processing';
case 'done': return 'badge-done';
case 'failed': return 'badge-failed';
default: return 'badge bg-gray-100 text-gray-800';
default: return 'badge bg-bg-tertiary text-text-secondary';
}
}
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 86400000) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function agentColor(name: string): string {
const colors = ['bg-accent-blue', 'bg-accent-green', 'bg-accent-purple', 'bg-accent-yellow', 'bg-accent-red'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const form = (e.target as HTMLElement).closest('form');
if (form) form.requestSubmit();
}
}
</script>
<div class="max-w-3xl mx-auto">
<a href="/conversations" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 mb-4 inline-block">
&larr; Back to conversations
</a>
<div class="flex flex-col h-full">
{#if loadingData}
<div class="card p-6">
<div class="p-5">
<div class="skeleton h-6 w-1/3 mb-4"></div>
<div class="space-y-3">
<div class="space-y-4">
{#each Array(3) as _}
<div class="skeleton h-16 w-full"></div>
<div class="flex gap-3">
<div class="skeleton w-9 h-9 rounded-lg flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="skeleton h-3 w-1/4"></div>
<div class="skeleton h-3 w-3/4"></div>
</div>
</div>
{/each}
</div>
</div>
{:else if conversation}
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h1 class="font-semibold text-lg text-gray-900 dark:text-gray-100">
{conversation.subject || 'Conversation #' + conversation.id}
</h1>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Started by {conversation.created_by} &middot; {messagesList.length} messages
</p>
</div>
<!-- Messages thread -->
<div class="divide-y divide-gray-100 dark:divide-gray-700 max-h-[60vh] overflow-y-auto">
{#each messagesList as msg (msg.id)}
<div class="px-4 py-3">
<div class="flex items-center gap-2 mb-1">
<div class="w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center text-xs font-medium text-primary-700 dark:text-primary-300">
{msg.from_agent.charAt(0).toUpperCase()}
</div>
<span class="font-medium text-sm text-gray-900 dark:text-gray-100">{msg.from_agent}</span>
{#if msg.to_agent}
<span class="text-xs text-gray-400">&rarr; {msg.to_agent}</span>
{/if}
<span class="{statusBadge(msg.status)}">{msg.status}</span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-auto">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<div class="ml-8 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{msg.body}</div>
</div>
{/each}
</div>
<!-- Reply form -->
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
{#if error}
<div class="mb-3 p-2 bg-red-50 dark:bg-red-900/30 rounded text-sm text-red-700 dark:text-red-300">{error}</div>
{/if}
<form class="flex gap-2" onsubmit={sendReply}>
<input type="text" class="input flex-1" placeholder="Type a reply..." bind:value={replyBody} />
<button type="submit" class="btn-primary" disabled={sending || !replyBody.trim()}>
{sending ? '...' : 'Send'}
</button>
</form>
<!-- Header -->
<div class="px-5 py-3 border-b border-border flex-shrink-0">
<div class="flex items-center gap-3">
<a href="/conversations" class="text-text-secondary hover:text-text-primary transition-colors" aria-label="Back to conversations">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</a>
<div>
<h1 class="font-semibold text-sm text-text-primary font-display">
{conversation.subject || 'Conversation #' + conversation.id}
</h1>
<p class="text-[11px] text-text-secondary">
Started by {conversation.created_by} -- {messagesList.length} messages
</p>
</div>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto">
{#each messagesList as msg (msg.id)}
<div class="px-5 py-3 hover:bg-bg-tertiary/30 transition-colors">
<div class="flex gap-3">
<div class="w-9 h-9 rounded-lg {agentColor(msg.from_agent)} flex items-center justify-center text-sm font-bold text-white flex-shrink-0">
{msg.from_agent.charAt(0).toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
{#if msg.to_agent}
<svg class="w-3 h-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span class="text-xs text-text-secondary">{msg.to_agent}</span>
{/if}
<span class="{statusBadge(msg.status)}">{msg.status}</span>
<span class="text-xs text-text-secondary ml-auto">{formatTime(msg.created_at)}</span>
</div>
<div class="text-sm text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</div>
</div>
</div>
</div>
{/each}
</div>
<!-- Reply form -->
<div class="border-t border-border p-4 flex-shrink-0">
{#if error}
<div class="mb-2 px-3 py-1.5 bg-accent-red/10 rounded text-xs text-accent-red">{error}</div>
{/if}
<form onsubmit={sendReply}>
<div class="bg-bg-input border border-border rounded-lg overflow-hidden focus-within:border-border-active">
<textarea
class="w-full px-4 py-3 bg-transparent text-sm text-text-primary placeholder-text-secondary resize-none outline-none"
placeholder="Type a reply..."
rows="2"
bind:value={replyBody}
onkeydown={handleKeydown}
></textarea>
<div class="flex items-center justify-end px-3 py-2">
<button
type="submit"
class="px-4 py-1.5 bg-accent-green rounded text-xs font-semibold text-white hover:brightness-110 transition-all disabled:opacity-50 flex items-center gap-1.5"
disabled={sending || !replyBody.trim()}
>
{#if sending}
Sending...
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send
{/if}
</button>
</div>
</div>
</form>
</div>
{:else}
<div class="card p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">Conversation not found</p>
<div class="p-8 text-center text-text-secondary text-sm">
Conversation not found
</div>
{/if}
</div>
+36 -16
View File
@@ -25,28 +25,42 @@
}
</script>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
<div class="w-full max-w-sm">
<div class="min-h-screen flex items-center justify-center bg-bg-primary px-4 relative overflow-hidden">
<!-- Background gradient mesh -->
<div class="absolute inset-0 opacity-30">
<div class="absolute top-1/4 left-1/4 w-96 h-96 bg-accent-purple/20 rounded-full blur-3xl"></div>
<div class="absolute bottom-1/4 right-1/4 w-96 h-96 bg-accent-blue/20 rounded-full blur-3xl"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-accent-green/10 rounded-full blur-3xl"></div>
</div>
<div class="w-full max-w-sm relative z-10">
<!-- Logo -->
<div class="text-center mb-8">
<svg class="w-12 h-12 mx-auto text-primary-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<h1 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">SynapBus</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Agent-to-agent messaging</p>
<div class="w-16 h-16 mx-auto rounded-2xl bg-accent-purple flex items-center justify-center mb-4">
<svg class="w-9 h-9 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<h1 class="text-2xl font-bold text-text-primary font-display">SynapBus</h1>
<p class="mt-1 text-sm text-text-secondary">Agent-to-agent messaging</p>
</div>
<form class="card p-6" onsubmit={handleSubmit}>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Sign In</h2>
<!-- Login card -->
<div class="card p-6 backdrop-blur-sm bg-bg-secondary/90">
<h2 class="text-base font-semibold text-text-primary font-display mb-5">Sign in to your workspace</h2>
{#if error}
<div class="mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
<div class="mb-4 px-3 py-2.5 bg-accent-red/10 border border-accent-red/20 rounded text-sm text-accent-red flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
{/if}
<div class="space-y-4">
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<label for="username" class="block text-xs font-medium text-text-secondary mb-1.5">Username</label>
<input
id="username"
type="text"
@@ -54,10 +68,11 @@
bind:value={username}
required
autocomplete="username"
placeholder="your-username"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<label for="password" class="block text-xs font-medium text-text-secondary mb-1.5">Password</label>
<input
id="password"
type="password"
@@ -65,12 +80,17 @@
bind:value={password}
required
autocomplete="current-password"
placeholder="••••••••"
/>
</div>
<button type="submit" class="btn-primary w-full" disabled={submitting}>
<button type="submit" class="btn-primary w-full py-2.5" disabled={submitting}>
{submitting ? 'Signing in...' : 'Sign In'}
</button>
</div>
</form>
</form>
</div>
<p class="mt-6 text-center text-xs text-text-secondary">
Mission control for AI agents
</p>
</div>
</div>
+47 -44
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import { theme, toggleTheme } from '$lib/stores/theme';
import { auth as authApi } from '$lib/api/client';
let currentPassword = $state('');
@@ -39,75 +38,79 @@
}
</script>
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Settings</h1>
<div class="p-5 max-w-2xl">
<h1 class="text-xl font-bold text-text-primary font-display mb-5">Settings</h1>
<!-- Appearance -->
<div class="card mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Appearance</h2>
<!-- Account -->
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Account</h2>
</div>
<div class="p-4">
<div class="flex items-center justify-between">
<div class="p-5">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="font-medium text-sm text-gray-900 dark:text-gray-100">Dark Mode</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Toggle between light and dark theme</p>
<p class="text-xs text-text-secondary mb-0.5">Username</p>
<p class="font-medium text-text-primary">{$user?.username ?? '-'}</p>
</div>
<div>
<p class="text-xs text-text-secondary mb-0.5">Display Name</p>
<p class="font-medium text-text-primary">{$user?.display_name ?? '-'}</p>
</div>
<div>
<p class="text-xs text-text-secondary mb-0.5">Role</p>
<p class="font-medium text-text-primary">{$user?.role ?? '-'}</p>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {$theme === 'dark' ? 'bg-primary-600' : 'bg-gray-300'}"
onclick={toggleTheme}
role="switch"
aria-checked={$theme === 'dark'}
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {$theme === 'dark' ? 'translate-x-6' : 'translate-x-1'}" />
</button>
</div>
</div>
</div>
<!-- Account -->
<div class="card mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Account</h2>
<!-- Quick links -->
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Management</h2>
</div>
<div class="p-4 space-y-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400">Username</p>
<p class="font-medium text-gray-900 dark:text-gray-100">{$user?.username ?? '-'}</p>
<div class="divide-y divide-border">
<a href="/settings/api-keys" class="flex items-center justify-between px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center gap-3">
<svg class="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div>
<p class="text-sm text-text-primary font-medium">API Keys</p>
<p class="text-xs text-text-secondary">Manage API keys for agent access</p>
</div>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Display Name</p>
<p class="font-medium text-gray-900 dark:text-gray-100">{$user?.display_name ?? '-'}</p>
</div>
</div>
<svg class="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
<!-- Change Password -->
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Change Password</h2>
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Change Password</h2>
</div>
<form class="p-4" onsubmit={handleChangePassword}>
<form class="p-5" onsubmit={handleChangePassword}>
{#if passwordError}
<div class="mb-3 p-2 bg-red-50 dark:bg-red-900/30 rounded text-sm text-red-700 dark:text-red-300">{passwordError}</div>
<div class="mb-3 px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{passwordError}</div>
{/if}
{#if passwordSuccess}
<div class="mb-3 p-2 bg-green-50 dark:bg-green-900/30 rounded text-sm text-green-700 dark:text-green-300">{passwordSuccess}</div>
<div class="mb-3 px-3 py-2 bg-accent-green/10 rounded text-xs text-accent-green">{passwordSuccess}</div>
{/if}
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
<input type="password" class="input" bind:value={currentPassword} required autocomplete="current-password" />
<label for="current-pw" class="block text-xs font-medium text-text-secondary mb-1.5">Current Password</label>
<input id="current-pw" type="password" class="input" bind:value={currentPassword} required autocomplete="current-password" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
<input type="password" class="input" bind:value={newPassword} required minlength="8" autocomplete="new-password" />
<label for="new-pw" class="block text-xs font-medium text-text-secondary mb-1.5">New Password</label>
<input id="new-pw" type="password" class="input" bind:value={newPassword} required minlength="8" autocomplete="new-password" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
<input type="password" class="input" bind:value={confirmPassword} required autocomplete="new-password" />
<label for="confirm-pw" class="block text-xs font-medium text-text-secondary mb-1.5">Confirm New Password</label>
<input id="confirm-pw" type="password" class="input" bind:value={confirmPassword} required autocomplete="new-password" />
</div>
<button type="submit" class="btn-primary" disabled={changingPassword}>
{changingPassword ? 'Changing...' : 'Change Password'}
@@ -0,0 +1,397 @@
<script lang="ts">
import { apiKeys, agents as agentsApi } from '$lib/api/client';
let keys = $state<any[]>([]);
let agentList = $state<any[]>([]);
let loadingData = $state(true);
// Create form state
let showCreate = $state(false);
let creating = $state(false);
let createError = $state('');
let newKeyName = $state('');
let newKeyAgentId = $state<number | undefined>(undefined);
let newKeyPermRead = $state(true);
let newKeyPermWrite = $state(true);
let newKeyPermAdmin = $state(false);
let newKeyChannels = $state('');
let newKeyReadOnly = $state(false);
let newKeyExpiry = $state('');
// Created key display
let createdKey = $state<{ key: any; api_key: string; mcp_config: any } | null>(null);
let copied = $state(false);
// Revoke
let confirmRevokeId = $state<number | null>(null);
let revoking = $state(false);
let _initialized = $state(false);
$effect(() => {
if (!_initialized) {
_initialized = true;
loadData();
}
});
async function loadData() {
loadingData = true;
try {
const [keysRes, agRes] = await Promise.all([
apiKeys.list(),
agentsApi.list()
]);
keys = keysRes.keys ?? [];
agentList = agRes.agents ?? [];
} catch {
// handled
} finally {
loadingData = false;
}
}
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
if (!newKeyName.trim()) {
createError = 'Key name is required';
return;
}
creating = true;
createError = '';
try {
const permissions: Record<string, boolean> = {};
if (newKeyPermRead) permissions.read = true;
if (newKeyPermWrite) permissions.write = true;
if (newKeyPermAdmin) permissions.admin = true;
const channels = newKeyChannels.trim()
? newKeyChannels.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
const res = await apiKeys.create({
name: newKeyName.trim(),
agent_id: newKeyAgentId,
permissions: Object.keys(permissions).length > 0 ? permissions : undefined,
allowed_channels: channels,
read_only: newKeyReadOnly || undefined,
expires_at: newKeyExpiry || undefined
});
createdKey = res;
newKeyName = '';
newKeyAgentId = undefined;
newKeyPermRead = true;
newKeyPermWrite = true;
newKeyPermAdmin = false;
newKeyChannels = '';
newKeyReadOnly = false;
newKeyExpiry = '';
showCreate = false;
await loadData();
} catch (err: any) {
createError = err.message || 'Failed to create API key';
} finally {
creating = false;
}
}
async function handleRevoke(id: number) {
revoking = true;
try {
await apiKeys.revoke(id);
confirmRevokeId = null;
await loadData();
} catch (err: any) {
alert(err.message || 'Failed to revoke key');
} finally {
revoking = false;
}
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch {
// fallback
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copied = true;
setTimeout(() => (copied = false), 2000);
}
}
function formatDate(iso: string): string {
if (!iso) return '-';
return new Date(iso).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' });
}
function getMcpConfig(apiKey: string): string {
return JSON.stringify({
mcpServers: {
synapbus: {
url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp`,
headers: {
Authorization: `Bearer ${apiKey}`
}
}
}
}, null, 2);
}
function getClaudeConfig(apiKey: string): string {
return JSON.stringify({
synapbus: {
type: 'streamableHttp',
url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp`,
headers: {
Authorization: `Bearer ${apiKey}`
}
}
}, null, 2);
}
</script>
<div class="p-5 max-w-5xl">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<a href="/settings" class="text-text-secondary hover:text-text-primary transition-colors" aria-label="Back to settings">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h1 class="text-xl font-bold text-text-primary font-display">API Keys</h1>
</div>
<button
class="btn-primary flex items-center gap-1.5"
onclick={() => { showCreate = !showCreate; createdKey = null; }}
>
{#if showCreate}
Cancel
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create API Key
{/if}
</button>
</div>
<!-- Created key display -->
{#if createdKey}
<div class="card mb-5 border-accent-yellow/30">
<div class="px-5 py-3 border-b border-border bg-accent-yellow/5">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-accent-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<span class="text-sm font-semibold text-accent-yellow">This key will only be shown once. Save it now.</span>
</div>
</div>
<div class="p-5 space-y-4">
<!-- The key -->
<div>
<p class="block text-xs font-medium text-text-secondary mb-1.5">API Key</p>
<div class="flex items-center gap-2">
<code class="flex-1 p-3 bg-bg-primary rounded-lg text-sm font-mono text-accent-green break-all select-all border border-accent-green/20">{createdKey.api_key}</code>
<button
class="flex-shrink-0 p-2.5 rounded-lg bg-bg-tertiary hover:bg-border-active text-text-secondary hover:text-text-primary transition-all"
onclick={() => copyToClipboard(createdKey?.api_key ?? '')}
title="Copy to clipboard"
>
{#if copied}
<svg class="w-4 h-4 text-accent-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
{/if}
</button>
</div>
</div>
<!-- MCP Config -->
<div>
<p class="block text-xs font-medium text-text-secondary mb-1.5">MCP Config (for MCP clients)</p>
<pre class="p-3 bg-bg-primary rounded-lg text-xs font-mono text-text-primary/80 overflow-x-auto border border-border">{getMcpConfig(createdKey.api_key)}</pre>
</div>
<!-- Claude Code config -->
<div>
<p class="block text-xs font-medium text-text-secondary mb-1.5">Claude Code Config (add to ~/.claude/mcp.json)</p>
<pre class="p-3 bg-bg-primary rounded-lg text-xs font-mono text-text-primary/80 overflow-x-auto border border-border">{getClaudeConfig(createdKey.api_key)}</pre>
</div>
</div>
<div class="px-5 py-3 border-t border-border">
<button
class="btn-secondary text-xs"
onclick={() => (createdKey = null)}
>
Dismiss
</button>
</div>
</div>
{/if}
<!-- Create form -->
{#if showCreate}
<form class="card p-5 mb-5" onsubmit={handleCreate}>
<h3 class="font-semibold text-sm text-text-primary font-display mb-4">Create New API Key</h3>
{#if createError}
<div class="mb-3 px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{createError}</div>
{/if}
<div class="space-y-4">
<div>
<label for="key-name" class="block text-xs font-medium text-text-secondary mb-1.5">Name</label>
<input id="key-name" type="text" class="input" placeholder="e.g. production-researcher" bind:value={newKeyName} required />
</div>
<div>
<label for="key-agent" class="block text-xs font-medium text-text-secondary mb-1.5">Agent (optional)</label>
<select id="key-agent" class="input" bind:value={newKeyAgentId}>
<option value={undefined}>No specific agent</option>
{#each agentList as agent}
<option value={agent.id}>{agent.display_name || agent.name} (@{agent.name})</option>
{/each}
</select>
</div>
<div>
<p class="block text-xs font-medium text-text-secondary mb-2">Permissions</p>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
<input type="checkbox" bind:checked={newKeyPermRead} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Read
</label>
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
<input type="checkbox" bind:checked={newKeyPermWrite} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Write
</label>
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
<input type="checkbox" bind:checked={newKeyPermAdmin} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Admin
</label>
</div>
</div>
<div>
<label for="key-channels" class="block text-xs font-medium text-text-secondary mb-1.5">Allowed Channels (comma-separated, leave empty for all)</label>
<input id="key-channels" type="text" class="input" placeholder="general, research, alerts" bind:value={newKeyChannels} />
</div>
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
<input type="checkbox" bind:checked={newKeyReadOnly} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Read-only mode
</label>
<div>
<label for="key-expiry" class="block text-xs font-medium text-text-secondary mb-1.5">Expiry Date (optional)</label>
<input id="key-expiry" type="date" class="input" bind:value={newKeyExpiry} />
</div>
<button type="submit" class="btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create API Key'}
</button>
</div>
</form>
{/if}
<!-- Keys list -->
<div class="card">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Your API Keys</h2>
</div>
{#if loadingData}
<div class="p-5 space-y-3">
{#each Array(3) as _}
<div class="skeleton h-10 w-full"></div>
{/each}
</div>
{:else if keys.length === 0}
<div class="p-8 text-center">
<svg class="w-10 h-10 mx-auto mb-3 text-border-active" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<p class="text-sm text-text-secondary">No API keys yet. Create one to get started.</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Name</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Prefix</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Permissions</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Channels</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Expires</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Created</th>
<th class="text-right px-5 py-2.5 text-xs font-medium text-text-secondary">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
{#each keys as key}
<tr class="hover:bg-bg-tertiary/30 transition-colors">
<td class="px-5 py-2.5 text-text-primary font-medium">{key.name}</td>
<td class="px-5 py-2.5 font-mono text-xs text-text-secondary">{key.prefix ?? 'sb_...'}</td>
<td class="px-5 py-2.5">
<div class="flex gap-1">
{#if key.permissions?.read}
<span class="badge bg-accent-blue/20 text-accent-blue">read</span>
{/if}
{#if key.permissions?.write}
<span class="badge bg-accent-green/20 text-accent-green">write</span>
{/if}
{#if key.permissions?.admin}
<span class="badge bg-accent-purple/20 text-accent-purple">admin</span>
{/if}
{#if key.read_only}
<span class="badge bg-accent-yellow/20 text-accent-yellow">RO</span>
{/if}
</div>
</td>
<td class="px-5 py-2.5 text-xs text-text-secondary">
{key.allowed_channels?.length ? key.allowed_channels.join(', ') : 'All'}
</td>
<td class="px-5 py-2.5 text-xs text-text-secondary">{formatDate(key.expires_at)}</td>
<td class="px-5 py-2.5 text-xs text-text-secondary">{formatDate(key.created_at)}</td>
<td class="px-5 py-2.5 text-right">
{#if confirmRevokeId === key.id}
<div class="flex items-center justify-end gap-1">
<button
class="px-2 py-1 text-xs bg-accent-red text-white rounded hover:brightness-110 transition-all disabled:opacity-50"
onclick={() => handleRevoke(key.id)}
disabled={revoking}
>
{revoking ? '...' : 'Confirm'}
</button>
<button
class="px-2 py-1 text-xs bg-bg-tertiary text-text-primary rounded hover:bg-border-active transition-all"
onclick={() => (confirmRevokeId = null)}
>
Cancel
</button>
</div>
{:else}
<button
class="px-2 py-1 text-xs text-accent-red hover:bg-accent-red/10 rounded transition-colors"
onclick={() => (confirmRevokeId = key.id)}
>
Revoke
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
+29 -13
View File
@@ -1,22 +1,38 @@
/** @type {import('tailwindcss').Config} */
function withOpacityValue(variable) {
return ({ opacityValue }) => {
if (opacityValue !== undefined) {
return `rgba(var(${variable}), ${opacityValue})`;
}
return `rgb(var(${variable}))`;
};
}
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a'
}
'bg-primary': '#1a1d21',
'bg-secondary': '#222529',
'bg-tertiary': '#2c2f33',
'bg-input': '#383b40',
'text-primary': '#e8e8e8',
'text-secondary': '#9b9da0',
'text-link': '#1d9bd1',
'accent-green': '#2eb67d',
'accent-yellow': '#ecb22e',
'accent-red': '#e01e5a',
'accent-blue': '#36c5f0',
'accent-purple': '#7c3aed',
'border': '#383b40',
'border-active': '#545760'
},
fontFamily: {
display: ['Instrument Sans', 'sans-serif'],
body: ['DM Sans', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace']
}
}
},