Complete BZZZ functionality port to CHORUS
🎭 CHORUS now contains full BZZZ functionality adapted for containers Core systems ported: - P2P networking (libp2p with DHT and PubSub) - Task coordination (COOEE protocol) - HMMM collaborative reasoning - SHHH encryption and security - SLURP admin election system - UCXL content addressing - UCXI server integration - Hypercore logging system - Health monitoring and graceful shutdown - License validation with KACHING Container adaptations: - Environment variable configuration (no YAML files) - Container-optimized logging to stdout/stderr - Auto-generated agent IDs for container deployments - Docker-first architecture All proven BZZZ P2P protocols, AI integration, and collaboration features are now available in containerized form. Next: Build and test container deployment. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
338
pkg/protocol/integration.go
Normal file
338
pkg/protocol/integration.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chorus.services/bzzz/pkg/config"
|
||||
"chorus.services/bzzz/pkg/dht"
|
||||
"chorus.services/bzzz/p2p"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
)
|
||||
|
||||
// ProtocolManager manages the BZZZ v2 protocol components
|
||||
type ProtocolManager struct {
|
||||
config *config.Config
|
||||
node *p2p.Node
|
||||
resolver *Resolver
|
||||
enabled bool
|
||||
|
||||
// Local peer information
|
||||
localPeer *PeerCapability
|
||||
}
|
||||
|
||||
// NewProtocolManager creates a new protocol manager
|
||||
func NewProtocolManager(cfg *config.Config, node *p2p.Node) (*ProtocolManager, error) {
|
||||
if cfg == nil || node == nil {
|
||||
return nil, fmt.Errorf("config and node are required")
|
||||
}
|
||||
|
||||
pm := &ProtocolManager{
|
||||
config: cfg,
|
||||
node: node,
|
||||
enabled: cfg.V2.Enabled,
|
||||
}
|
||||
|
||||
// Only initialize if v2 protocol is enabled
|
||||
if pm.enabled {
|
||||
if err := pm.initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize protocol manager: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
// initialize sets up the protocol components
|
||||
func (pm *ProtocolManager) initialize() error {
|
||||
// Create resolver
|
||||
resolverOpts := []ResolverOption{
|
||||
WithCacheTTL(pm.config.V2.URIResolution.CacheTTL),
|
||||
WithMaxPeersPerResult(pm.config.V2.URIResolution.MaxPeersPerResult),
|
||||
}
|
||||
|
||||
// Set default strategy
|
||||
switch pm.config.V2.URIResolution.DefaultStrategy {
|
||||
case "exact":
|
||||
resolverOpts = append(resolverOpts, WithDefaultStrategy(StrategyExact))
|
||||
case "priority":
|
||||
resolverOpts = append(resolverOpts, WithDefaultStrategy(StrategyPriority))
|
||||
case "load_balance":
|
||||
resolverOpts = append(resolverOpts, WithDefaultStrategy(StrategyLoadBalance))
|
||||
default:
|
||||
resolverOpts = append(resolverOpts, WithDefaultStrategy(StrategyBestMatch))
|
||||
}
|
||||
|
||||
pm.resolver = NewResolver(pm.node.Host().Peerstore(), resolverOpts...)
|
||||
|
||||
// Initialize local peer information
|
||||
pm.localPeer = &PeerCapability{
|
||||
PeerID: pm.node.ID(),
|
||||
Agent: pm.config.Agent.ID,
|
||||
Role: pm.config.Agent.Role,
|
||||
Capabilities: pm.config.Agent.Capabilities,
|
||||
Models: pm.config.Agent.Models,
|
||||
Specialization: pm.config.Agent.Specialization,
|
||||
LastSeen: time.Now(),
|
||||
Status: "ready",
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
|
||||
// Add project information if available
|
||||
if project := pm.getProjectFromConfig(); project != "" {
|
||||
pm.localPeer.Metadata["project"] = project
|
||||
}
|
||||
|
||||
// Register local peer
|
||||
pm.resolver.RegisterPeer(pm.node.ID(), pm.localPeer)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the v2 protocol is enabled
|
||||
func (pm *ProtocolManager) IsEnabled() bool {
|
||||
return pm.enabled
|
||||
}
|
||||
|
||||
// ResolveURI resolves a bzzz:// URI to peer addresses
|
||||
func (pm *ProtocolManager) ResolveURI(ctx context.Context, uriStr string) (*ResolutionResult, error) {
|
||||
if !pm.enabled {
|
||||
return nil, fmt.Errorf("v2 protocol not enabled")
|
||||
}
|
||||
|
||||
return pm.resolver.ResolveString(ctx, uriStr)
|
||||
}
|
||||
|
||||
// RegisterPeer registers a peer's capabilities
|
||||
func (pm *ProtocolManager) RegisterPeer(peerID peer.ID, capabilities *PeerCapability) {
|
||||
if !pm.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
pm.resolver.RegisterPeer(peerID, capabilities)
|
||||
|
||||
// Announce to DHT if enabled
|
||||
if pm.node.IsDHTEnabled() {
|
||||
pm.announcePeerToDHT(context.Background(), capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLocalPeerStatus updates the local peer's status
|
||||
func (pm *ProtocolManager) UpdateLocalPeerStatus(status string) {
|
||||
if !pm.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
pm.localPeer.Status = status
|
||||
pm.localPeer.LastSeen = time.Now()
|
||||
|
||||
pm.resolver.RegisterPeer(pm.node.ID(), pm.localPeer)
|
||||
}
|
||||
|
||||
// GetLocalPeer returns the local peer information
|
||||
func (pm *ProtocolManager) GetLocalPeer() *PeerCapability {
|
||||
return pm.localPeer
|
||||
}
|
||||
|
||||
// GetAllPeers returns all known peers
|
||||
func (pm *ProtocolManager) GetAllPeers() map[peer.ID]*PeerCapability {
|
||||
if !pm.enabled {
|
||||
return make(map[peer.ID]*PeerCapability)
|
||||
}
|
||||
|
||||
return pm.resolver.GetPeerCapabilities()
|
||||
}
|
||||
|
||||
// HandlePeerCapabilityMessage handles incoming peer capability messages
|
||||
func (pm *ProtocolManager) HandlePeerCapabilityMessage(peerID peer.ID, data []byte) error {
|
||||
if !pm.enabled {
|
||||
return nil // Silently ignore if v2 not enabled
|
||||
}
|
||||
|
||||
var capability PeerCapability
|
||||
if err := json.Unmarshal(data, &capability); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal capability message: %w", err)
|
||||
}
|
||||
|
||||
capability.PeerID = peerID
|
||||
capability.LastSeen = time.Now()
|
||||
|
||||
pm.resolver.RegisterPeer(peerID, &capability)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AnnounceCapabilities announces the local peer's capabilities
|
||||
func (pm *ProtocolManager) AnnounceCapabilities() error {
|
||||
if !pm.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update local peer information
|
||||
pm.localPeer.LastSeen = time.Now()
|
||||
|
||||
// Announce to DHT if enabled
|
||||
if pm.node.IsDHTEnabled() {
|
||||
return pm.announcePeerToDHT(context.Background(), pm.localPeer)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// announcePeerToDHT announces a peer's capabilities to the DHT
|
||||
func (pm *ProtocolManager) announcePeerToDHT(ctx context.Context, capability *PeerCapability) error {
|
||||
dht := pm.node.DHT()
|
||||
if dht == nil {
|
||||
return fmt.Errorf("DHT not available")
|
||||
}
|
||||
|
||||
// Register peer with role-based and capability-based keys
|
||||
if capability.Role != "" {
|
||||
dht.RegisterPeer(capability.PeerID, capability.Agent, capability.Role, capability.Capabilities)
|
||||
if err := dht.AnnounceRole(ctx, capability.Role); err != nil {
|
||||
// Log error but don't fail
|
||||
}
|
||||
}
|
||||
|
||||
// Announce each capability
|
||||
for _, cap := range capability.Capabilities {
|
||||
if err := dht.AnnounceCapability(ctx, cap); err != nil {
|
||||
// Log error but don't fail
|
||||
}
|
||||
}
|
||||
|
||||
// Announce general peer presence
|
||||
if err := dht.Provide(ctx, "bzzz:peer"); err != nil {
|
||||
// Log error but don't fail
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindPeersByRole finds peers with a specific role
|
||||
func (pm *ProtocolManager) FindPeersByRole(ctx context.Context, role string) ([]*PeerCapability, error) {
|
||||
if !pm.enabled {
|
||||
return nil, fmt.Errorf("v2 protocol not enabled")
|
||||
}
|
||||
|
||||
// First try DHT if available
|
||||
if pm.node.IsDHTEnabled() {
|
||||
dhtPeers, err := pm.node.DHT().FindPeersByRole(ctx, role)
|
||||
if err == nil && len(dhtPeers) > 0 {
|
||||
// Convert DHT peer info to capabilities
|
||||
var capabilities []*PeerCapability
|
||||
for _, dhtPeer := range dhtPeers {
|
||||
cap := &PeerCapability{
|
||||
PeerID: dhtPeer.ID,
|
||||
Agent: dhtPeer.Agent,
|
||||
Role: dhtPeer.Role,
|
||||
LastSeen: dhtPeer.LastSeen,
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
capabilities = append(capabilities, cap)
|
||||
}
|
||||
return capabilities, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to local resolver
|
||||
var result []*PeerCapability
|
||||
for _, peer := range pm.resolver.GetPeerCapabilities() {
|
||||
if peer.Role == role || role == "*" {
|
||||
result = append(result, peer)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ValidateURI validates a bzzz:// URI
|
||||
func (pm *ProtocolManager) ValidateURI(uriStr string) error {
|
||||
if !pm.enabled {
|
||||
return fmt.Errorf("v2 protocol not enabled")
|
||||
}
|
||||
|
||||
_, err := ParseBzzzURI(uriStr)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateURI creates a bzzz:// URI with the given components
|
||||
func (pm *ProtocolManager) CreateURI(agent, role, project, task, path string) (*BzzzURI, error) {
|
||||
if !pm.enabled {
|
||||
return nil, fmt.Errorf("v2 protocol not enabled")
|
||||
}
|
||||
|
||||
// Use configured defaults if components are empty
|
||||
if agent == "" {
|
||||
agent = pm.config.V2.SemanticAddressing.DefaultAgent
|
||||
}
|
||||
if role == "" {
|
||||
role = pm.config.V2.SemanticAddressing.DefaultRole
|
||||
}
|
||||
if project == "" {
|
||||
project = pm.config.V2.SemanticAddressing.DefaultProject
|
||||
}
|
||||
|
||||
return NewBzzzURI(agent, role, project, task, path), nil
|
||||
}
|
||||
|
||||
// GetFeatureFlags returns the current feature flags
|
||||
func (pm *ProtocolManager) GetFeatureFlags() map[string]bool {
|
||||
return pm.config.V2.FeatureFlags
|
||||
}
|
||||
|
||||
// IsFeatureEnabled checks if a specific feature is enabled
|
||||
func (pm *ProtocolManager) IsFeatureEnabled(feature string) bool {
|
||||
if !pm.enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
enabled, exists := pm.config.V2.FeatureFlags[feature]
|
||||
return exists && enabled
|
||||
}
|
||||
|
||||
// Close shuts down the protocol manager
|
||||
func (pm *ProtocolManager) Close() error {
|
||||
if pm.resolver != nil {
|
||||
return pm.resolver.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getProjectFromConfig extracts project information from configuration
|
||||
func (pm *ProtocolManager) getProjectFromConfig() string {
|
||||
// Try to infer project from agent ID or other configuration
|
||||
if pm.config.Agent.ID != "" {
|
||||
parts := strings.Split(pm.config.Agent.ID, "-")
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Default project if none can be inferred
|
||||
return "bzzz"
|
||||
}
|
||||
|
||||
// GetStats returns protocol statistics
|
||||
func (pm *ProtocolManager) GetStats() map[string]interface{} {
|
||||
stats := map[string]interface{}{
|
||||
"enabled": pm.enabled,
|
||||
"local_peer": pm.localPeer,
|
||||
"known_peers": len(pm.resolver.GetPeerCapabilities()),
|
||||
}
|
||||
|
||||
if pm.node.IsDHTEnabled() {
|
||||
dht := pm.node.DHT()
|
||||
stats["dht_enabled"] = true
|
||||
stats["dht_bootstrapped"] = dht.IsBootstrapped()
|
||||
stats["dht_size"] = dht.GetDHTSize()
|
||||
stats["dht_connected_peers"] = len(dht.GetConnectedPeers())
|
||||
} else {
|
||||
stats["dht_enabled"] = false
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
551
pkg/protocol/resolver.go
Normal file
551
pkg/protocol/resolver.go
Normal file
@@ -0,0 +1,551 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"github.com/libp2p/go-libp2p/core/peerstore"
|
||||
)
|
||||
|
||||
// PeerCapability represents the capabilities of a peer
|
||||
type PeerCapability struct {
|
||||
PeerID peer.ID `json:"peer_id"`
|
||||
Agent string `json:"agent"`
|
||||
Role string `json:"role"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Models []string `json:"models"`
|
||||
Specialization string `json:"specialization"`
|
||||
Project string `json:"project"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Status string `json:"status"` // "online", "busy", "offline"
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// PeerAddress represents a resolved peer address
|
||||
type PeerAddress struct {
|
||||
PeerID peer.ID `json:"peer_id"`
|
||||
Addresses []string `json:"addresses"`
|
||||
Priority int `json:"priority"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// ResolutionResult represents the result of address resolution
|
||||
type ResolutionResult struct {
|
||||
URI *BzzzURI `json:"uri"`
|
||||
Peers []*PeerAddress `json:"peers"`
|
||||
ResolvedAt time.Time `json:"resolved_at"`
|
||||
ResolutionTTL time.Duration `json:"ttl"`
|
||||
Strategy string `json:"strategy"`
|
||||
}
|
||||
|
||||
// ResolutionStrategy defines how to resolve addresses
|
||||
type ResolutionStrategy string
|
||||
|
||||
const (
|
||||
StrategyExact ResolutionStrategy = "exact" // Exact match only
|
||||
StrategyBestMatch ResolutionStrategy = "best_match" // Best available match
|
||||
StrategyLoadBalance ResolutionStrategy = "load_balance" // Load balance among matches
|
||||
StrategyPriority ResolutionStrategy = "priority" // Highest priority first
|
||||
)
|
||||
|
||||
// Resolver handles semantic address resolution
|
||||
type Resolver struct {
|
||||
// Peer capability registry
|
||||
capabilities map[peer.ID]*PeerCapability
|
||||
capMutex sync.RWMutex
|
||||
|
||||
// Address resolution cache
|
||||
cache map[string]*ResolutionResult
|
||||
cacheMutex sync.RWMutex
|
||||
cacheTTL time.Duration
|
||||
|
||||
// Configuration
|
||||
defaultStrategy ResolutionStrategy
|
||||
maxPeersPerResult int
|
||||
|
||||
// Peerstore for address information
|
||||
peerstore peerstore.Peerstore
|
||||
}
|
||||
|
||||
// NewResolver creates a new semantic address resolver
|
||||
func NewResolver(peerstore peerstore.Peerstore, opts ...ResolverOption) *Resolver {
|
||||
r := &Resolver{
|
||||
capabilities: make(map[peer.ID]*PeerCapability),
|
||||
cache: make(map[string]*ResolutionResult),
|
||||
cacheTTL: 5 * time.Minute,
|
||||
defaultStrategy: StrategyBestMatch,
|
||||
maxPeersPerResult: 5,
|
||||
peerstore: peerstore,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
|
||||
// Start background cleanup
|
||||
go r.startCleanup()
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ResolverOption configures the resolver
|
||||
type ResolverOption func(*Resolver)
|
||||
|
||||
// WithCacheTTL sets the cache TTL
|
||||
func WithCacheTTL(ttl time.Duration) ResolverOption {
|
||||
return func(r *Resolver) {
|
||||
r.cacheTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultStrategy sets the default resolution strategy
|
||||
func WithDefaultStrategy(strategy ResolutionStrategy) ResolverOption {
|
||||
return func(r *Resolver) {
|
||||
r.defaultStrategy = strategy
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxPeersPerResult sets the maximum peers per result
|
||||
func WithMaxPeersPerResult(max int) ResolverOption {
|
||||
return func(r *Resolver) {
|
||||
r.maxPeersPerResult = max
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPeer registers a peer's capabilities
|
||||
func (r *Resolver) RegisterPeer(peerID peer.ID, capability *PeerCapability) {
|
||||
r.capMutex.Lock()
|
||||
defer r.capMutex.Unlock()
|
||||
|
||||
capability.PeerID = peerID
|
||||
capability.LastSeen = time.Now()
|
||||
r.capabilities[peerID] = capability
|
||||
|
||||
// Clear relevant cache entries
|
||||
r.invalidateCache()
|
||||
}
|
||||
|
||||
// UnregisterPeer removes a peer from the registry
|
||||
func (r *Resolver) UnregisterPeer(peerID peer.ID) {
|
||||
r.capMutex.Lock()
|
||||
defer r.capMutex.Unlock()
|
||||
|
||||
delete(r.capabilities, peerID)
|
||||
|
||||
// Clear relevant cache entries
|
||||
r.invalidateCache()
|
||||
}
|
||||
|
||||
// UpdatePeerStatus updates a peer's status
|
||||
func (r *Resolver) UpdatePeerStatus(peerID peer.ID, status string) {
|
||||
r.capMutex.Lock()
|
||||
defer r.capMutex.Unlock()
|
||||
|
||||
if cap, exists := r.capabilities[peerID]; exists {
|
||||
cap.Status = status
|
||||
cap.LastSeen = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve resolves a bzzz:// URI to peer addresses
|
||||
func (r *Resolver) Resolve(ctx context.Context, uri *BzzzURI, strategy ...ResolutionStrategy) (*ResolutionResult, error) {
|
||||
if uri == nil {
|
||||
return nil, fmt.Errorf("nil URI")
|
||||
}
|
||||
|
||||
// Determine strategy
|
||||
resolveStrategy := r.defaultStrategy
|
||||
if len(strategy) > 0 {
|
||||
resolveStrategy = strategy[0]
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := r.getCacheKey(uri, resolveStrategy)
|
||||
if result := r.getFromCache(cacheKey); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Perform resolution
|
||||
result, err := r.resolveURI(ctx, uri, resolveStrategy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache result
|
||||
r.cacheResult(cacheKey, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveString resolves a bzzz:// URI string to peer addresses
|
||||
func (r *Resolver) ResolveString(ctx context.Context, uriStr string, strategy ...ResolutionStrategy) (*ResolutionResult, error) {
|
||||
uri, err := ParseBzzzURI(uriStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse URI: %w", err)
|
||||
}
|
||||
|
||||
return r.Resolve(ctx, uri, strategy...)
|
||||
}
|
||||
|
||||
// resolveURI performs the actual URI resolution
|
||||
func (r *Resolver) resolveURI(ctx context.Context, uri *BzzzURI, strategy ResolutionStrategy) (*ResolutionResult, error) {
|
||||
r.capMutex.RLock()
|
||||
defer r.capMutex.RUnlock()
|
||||
|
||||
var matchingPeers []*PeerCapability
|
||||
|
||||
// Find matching peers
|
||||
for _, cap := range r.capabilities {
|
||||
if r.peerMatches(cap, uri) {
|
||||
matchingPeers = append(matchingPeers, cap)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matchingPeers) == 0 {
|
||||
return &ResolutionResult{
|
||||
URI: uri,
|
||||
Peers: []*PeerAddress{},
|
||||
ResolvedAt: time.Now(),
|
||||
ResolutionTTL: r.cacheTTL,
|
||||
Strategy: string(strategy),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Apply resolution strategy
|
||||
selectedPeers := r.applyStrategy(matchingPeers, strategy)
|
||||
|
||||
// Convert to peer addresses
|
||||
var peerAddresses []*PeerAddress
|
||||
for i, cap := range selectedPeers {
|
||||
if i >= r.maxPeersPerResult {
|
||||
break
|
||||
}
|
||||
|
||||
addr := &PeerAddress{
|
||||
PeerID: cap.PeerID,
|
||||
Priority: r.calculatePriority(cap, uri),
|
||||
Metadata: map[string]interface{}{
|
||||
"agent": cap.Agent,
|
||||
"role": cap.Role,
|
||||
"specialization": cap.Specialization,
|
||||
"status": cap.Status,
|
||||
"last_seen": cap.LastSeen,
|
||||
},
|
||||
}
|
||||
|
||||
// Get addresses from peerstore
|
||||
if r.peerstore != nil {
|
||||
addrs := r.peerstore.Addrs(cap.PeerID)
|
||||
for _, ma := range addrs {
|
||||
addr.Addresses = append(addr.Addresses, ma.String())
|
||||
}
|
||||
}
|
||||
|
||||
peerAddresses = append(peerAddresses, addr)
|
||||
}
|
||||
|
||||
return &ResolutionResult{
|
||||
URI: uri,
|
||||
Peers: peerAddresses,
|
||||
ResolvedAt: time.Now(),
|
||||
ResolutionTTL: r.cacheTTL,
|
||||
Strategy: string(strategy),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// peerMatches checks if a peer matches the URI criteria
|
||||
func (r *Resolver) peerMatches(cap *PeerCapability, uri *BzzzURI) bool {
|
||||
// Check if peer is online
|
||||
if cap.Status == "offline" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check agent match
|
||||
if !IsWildcard(uri.Agent) && !componentMatches(uri.Agent, cap.Agent) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check role match
|
||||
if !IsWildcard(uri.Role) && !componentMatches(uri.Role, cap.Role) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check project match (if specified in metadata)
|
||||
if !IsWildcard(uri.Project) {
|
||||
if project, exists := cap.Metadata["project"]; exists {
|
||||
if !componentMatches(uri.Project, project) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check task capabilities (if peer has relevant capabilities)
|
||||
if !IsWildcard(uri.Task) {
|
||||
taskMatches := false
|
||||
for _, capability := range cap.Capabilities {
|
||||
if componentMatches(uri.Task, capability) {
|
||||
taskMatches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !taskMatches {
|
||||
// Also check specialization
|
||||
if !componentMatches(uri.Task, cap.Specialization) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// applyStrategy applies the resolution strategy to matching peers
|
||||
func (r *Resolver) applyStrategy(peers []*PeerCapability, strategy ResolutionStrategy) []*PeerCapability {
|
||||
switch strategy {
|
||||
case StrategyExact:
|
||||
// Return only exact matches (already filtered)
|
||||
return peers
|
||||
|
||||
case StrategyPriority:
|
||||
// Sort by priority (calculated based on specificity and status)
|
||||
return r.sortByPriority(peers)
|
||||
|
||||
case StrategyLoadBalance:
|
||||
// Sort by load (prefer less busy peers)
|
||||
return r.sortByLoad(peers)
|
||||
|
||||
case StrategyBestMatch:
|
||||
fallthrough
|
||||
default:
|
||||
// Sort by best match score
|
||||
return r.sortByMatch(peers)
|
||||
}
|
||||
}
|
||||
|
||||
// sortByPriority sorts peers by priority score
|
||||
func (r *Resolver) sortByPriority(peers []*PeerCapability) []*PeerCapability {
|
||||
// Simple priority: online > working > busy, then by last seen
|
||||
result := make([]*PeerCapability, len(peers))
|
||||
copy(result, peers)
|
||||
|
||||
// Sort by status priority and recency
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
iPriority := r.getStatusPriority(result[i].Status)
|
||||
jPriority := r.getStatusPriority(result[j].Status)
|
||||
|
||||
if iPriority < jPriority ||
|
||||
(iPriority == jPriority && result[i].LastSeen.Before(result[j].LastSeen)) {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// sortByLoad sorts peers by current load (prefer less busy)
|
||||
func (r *Resolver) sortByLoad(peers []*PeerCapability) []*PeerCapability {
|
||||
result := make([]*PeerCapability, len(peers))
|
||||
copy(result, peers)
|
||||
|
||||
// Sort by status (ready > working > busy)
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
iLoad := r.getLoadScore(result[i].Status)
|
||||
jLoad := r.getLoadScore(result[j].Status)
|
||||
|
||||
if iLoad > jLoad {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// sortByMatch sorts peers by match quality
|
||||
func (r *Resolver) sortByMatch(peers []*PeerCapability) []*PeerCapability {
|
||||
result := make([]*PeerCapability, len(peers))
|
||||
copy(result, peers)
|
||||
|
||||
// Simple sorting - prefer online status and recent activity
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
if r.getMatchScore(result[i]) < r.getMatchScore(result[j]) {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Helper functions for scoring
|
||||
func (r *Resolver) getStatusPriority(status string) int {
|
||||
switch status {
|
||||
case "ready":
|
||||
return 3
|
||||
case "working":
|
||||
return 2
|
||||
case "busy":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) getLoadScore(status string) int {
|
||||
switch status {
|
||||
case "ready":
|
||||
return 0 // Lowest load
|
||||
case "working":
|
||||
return 1
|
||||
case "busy":
|
||||
return 2 // Highest load
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) getMatchScore(cap *PeerCapability) int {
|
||||
score := 0
|
||||
|
||||
// Status contribution
|
||||
score += r.getStatusPriority(cap.Status) * 10
|
||||
|
||||
// Recency contribution (more recent = higher score)
|
||||
timeSince := time.Since(cap.LastSeen)
|
||||
if timeSince < time.Minute {
|
||||
score += 5
|
||||
} else if timeSince < time.Hour {
|
||||
score += 3
|
||||
} else if timeSince < 24*time.Hour {
|
||||
score += 1
|
||||
}
|
||||
|
||||
// Capability count contribution
|
||||
score += len(cap.Capabilities)
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// calculatePriority calculates priority for a peer address
|
||||
func (r *Resolver) calculatePriority(cap *PeerCapability, uri *BzzzURI) int {
|
||||
priority := 0
|
||||
|
||||
// Exact matches get higher priority
|
||||
if cap.Agent == uri.Agent {
|
||||
priority += 4
|
||||
}
|
||||
if cap.Role == uri.Role {
|
||||
priority += 3
|
||||
}
|
||||
if cap.Specialization == uri.Task {
|
||||
priority += 2
|
||||
}
|
||||
|
||||
// Status-based priority
|
||||
priority += r.getStatusPriority(cap.Status)
|
||||
|
||||
return priority
|
||||
}
|
||||
|
||||
// Cache management
|
||||
func (r *Resolver) getCacheKey(uri *BzzzURI, strategy ResolutionStrategy) string {
|
||||
return fmt.Sprintf("%s:%s", uri.String(), strategy)
|
||||
}
|
||||
|
||||
func (r *Resolver) getFromCache(key string) *ResolutionResult {
|
||||
r.cacheMutex.RLock()
|
||||
defer r.cacheMutex.RUnlock()
|
||||
|
||||
if result, exists := r.cache[key]; exists {
|
||||
// Check if result is still valid
|
||||
if time.Since(result.ResolvedAt) < result.ResolutionTTL {
|
||||
return result
|
||||
}
|
||||
|
||||
// Remove expired entry
|
||||
delete(r.cache, key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Resolver) cacheResult(key string, result *ResolutionResult) {
|
||||
r.cacheMutex.Lock()
|
||||
defer r.cacheMutex.Unlock()
|
||||
|
||||
r.cache[key] = result
|
||||
}
|
||||
|
||||
func (r *Resolver) invalidateCache() {
|
||||
r.cacheMutex.Lock()
|
||||
defer r.cacheMutex.Unlock()
|
||||
|
||||
// Clear entire cache on capability changes
|
||||
r.cache = make(map[string]*ResolutionResult)
|
||||
}
|
||||
|
||||
// startCleanup starts background cache cleanup
|
||||
func (r *Resolver) startCleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
r.cleanupCache()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) cleanupCache() {
|
||||
r.cacheMutex.Lock()
|
||||
defer r.cacheMutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, result := range r.cache {
|
||||
if now.Sub(result.ResolvedAt) > result.ResolutionTTL {
|
||||
delete(r.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetPeerCapabilities returns all registered peer capabilities
|
||||
func (r *Resolver) GetPeerCapabilities() map[peer.ID]*PeerCapability {
|
||||
r.capMutex.RLock()
|
||||
defer r.capMutex.RUnlock()
|
||||
|
||||
result := make(map[peer.ID]*PeerCapability)
|
||||
for id, cap := range r.capabilities {
|
||||
result[id] = cap
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetPeerCapability returns a specific peer's capabilities
|
||||
func (r *Resolver) GetPeerCapability(peerID peer.ID) (*PeerCapability, bool) {
|
||||
r.capMutex.RLock()
|
||||
defer r.capMutex.RUnlock()
|
||||
|
||||
cap, exists := r.capabilities[peerID]
|
||||
return cap, exists
|
||||
}
|
||||
|
||||
// Close shuts down the resolver
|
||||
func (r *Resolver) Close() error {
|
||||
// Clear all data
|
||||
r.capMutex.Lock()
|
||||
r.capabilities = make(map[peer.ID]*PeerCapability)
|
||||
r.capMutex.Unlock()
|
||||
|
||||
r.cacheMutex.Lock()
|
||||
r.cache = make(map[string]*ResolutionResult)
|
||||
r.cacheMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
456
pkg/protocol/resolver_test.go
Normal file
456
pkg/protocol/resolver_test.go
Normal file
@@ -0,0 +1,456 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"github.com/libp2p/go-libp2p/core/peerstore"
|
||||
"github.com/libp2p/go-libp2p/core/test"
|
||||
)
|
||||
|
||||
func TestNewResolver(t *testing.T) {
|
||||
// Create a mock peerstore
|
||||
mockPeerstore := &mockPeerstore{}
|
||||
|
||||
resolver := NewResolver(mockPeerstore)
|
||||
|
||||
if resolver == nil {
|
||||
t.Fatal("resolver is nil")
|
||||
}
|
||||
|
||||
if resolver.peerstore != mockPeerstore {
|
||||
t.Error("peerstore not set correctly")
|
||||
}
|
||||
|
||||
if resolver.defaultStrategy != StrategyBestMatch {
|
||||
t.Errorf("expected default strategy %v, got %v", StrategyBestMatch, resolver.defaultStrategy)
|
||||
}
|
||||
|
||||
if resolver.maxPeersPerResult != 5 {
|
||||
t.Errorf("expected max peers per result 5, got %d", resolver.maxPeersPerResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverWithOptions(t *testing.T) {
|
||||
mockPeerstore := &mockPeerstore{}
|
||||
|
||||
resolver := NewResolver(mockPeerstore,
|
||||
WithCacheTTL(10*time.Minute),
|
||||
WithDefaultStrategy(StrategyPriority),
|
||||
WithMaxPeersPerResult(10),
|
||||
)
|
||||
|
||||
if resolver.cacheTTL != 10*time.Minute {
|
||||
t.Errorf("expected cache TTL 10m, got %v", resolver.cacheTTL)
|
||||
}
|
||||
|
||||
if resolver.defaultStrategy != StrategyPriority {
|
||||
t.Errorf("expected strategy %v, got %v", StrategyPriority, resolver.defaultStrategy)
|
||||
}
|
||||
|
||||
if resolver.maxPeersPerResult != 10 {
|
||||
t.Errorf("expected max peers 10, got %d", resolver.maxPeersPerResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPeer(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
capability := &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Capabilities: []string{"react", "javascript"},
|
||||
Models: []string{"claude-3"},
|
||||
Specialization: "frontend",
|
||||
Status: "ready",
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
|
||||
resolver.RegisterPeer(peerID, capability)
|
||||
|
||||
// Verify peer was registered
|
||||
caps := resolver.GetPeerCapabilities()
|
||||
if len(caps) != 1 {
|
||||
t.Errorf("expected 1 peer, got %d", len(caps))
|
||||
}
|
||||
|
||||
registeredCap, exists := caps[peerID]
|
||||
if !exists {
|
||||
t.Error("peer not found in capabilities")
|
||||
}
|
||||
|
||||
if registeredCap.Agent != capability.Agent {
|
||||
t.Errorf("expected agent %s, got %s", capability.Agent, registeredCap.Agent)
|
||||
}
|
||||
|
||||
if registeredCap.PeerID != peerID {
|
||||
t.Error("peer ID not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisterPeer(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
capability := &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
}
|
||||
|
||||
// Register then unregister
|
||||
resolver.RegisterPeer(peerID, capability)
|
||||
resolver.UnregisterPeer(peerID)
|
||||
|
||||
caps := resolver.GetPeerCapabilities()
|
||||
if len(caps) != 0 {
|
||||
t.Errorf("expected 0 peers after unregister, got %d", len(caps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePeerStatus(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
capability := &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Status: "ready",
|
||||
}
|
||||
|
||||
resolver.RegisterPeer(peerID, capability)
|
||||
resolver.UpdatePeerStatus(peerID, "busy")
|
||||
|
||||
caps := resolver.GetPeerCapabilities()
|
||||
updatedCap := caps[peerID]
|
||||
|
||||
if updatedCap.Status != "busy" {
|
||||
t.Errorf("expected status 'busy', got '%s'", updatedCap.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveURI(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
// Register some test peers
|
||||
peerID1 := test.RandPeerIDFatal(t)
|
||||
peerID2 := test.RandPeerIDFatal(t)
|
||||
|
||||
resolver.RegisterPeer(peerID1, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Capabilities: []string{"react", "javascript"},
|
||||
Status: "ready",
|
||||
Metadata: map[string]string{"project": "chorus"},
|
||||
})
|
||||
|
||||
resolver.RegisterPeer(peerID2, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "backend",
|
||||
Capabilities: []string{"go", "api"},
|
||||
Status: "ready",
|
||||
Metadata: map[string]string{"project": "chorus"},
|
||||
})
|
||||
|
||||
// Test exact match
|
||||
uri, err := ParseBzzzURI("bzzz://claude:frontend@chorus:react")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse URI: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := resolver.Resolve(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve URI: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Peers) != 1 {
|
||||
t.Errorf("expected 1 peer in result, got %d", len(result.Peers))
|
||||
}
|
||||
|
||||
if result.Peers[0].PeerID != peerID1 {
|
||||
t.Error("wrong peer returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveURIWithWildcards(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
peerID1 := test.RandPeerIDFatal(t)
|
||||
peerID2 := test.RandPeerIDFatal(t)
|
||||
|
||||
resolver.RegisterPeer(peerID1, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Capabilities: []string{"react"},
|
||||
Status: "ready",
|
||||
})
|
||||
|
||||
resolver.RegisterPeer(peerID2, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "backend",
|
||||
Capabilities: []string{"go"},
|
||||
Status: "ready",
|
||||
})
|
||||
|
||||
// Test wildcard match
|
||||
uri, err := ParseBzzzURI("bzzz://claude:*@*:*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse URI: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := resolver.Resolve(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve URI: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Peers) != 2 {
|
||||
t.Errorf("expected 2 peers in result, got %d", len(result.Peers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveURIWithOfflinePeers(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
|
||||
resolver.RegisterPeer(peerID, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Status: "offline", // This peer should be filtered out
|
||||
})
|
||||
|
||||
uri, err := ParseBzzzURI("bzzz://claude:frontend@*:*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse URI: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := resolver.Resolve(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve URI: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Peers) != 0 {
|
||||
t.Errorf("expected 0 peers (offline filtered), got %d", len(result.Peers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveString(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
resolver.RegisterPeer(peerID, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Status: "ready",
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := resolver.ResolveString(ctx, "bzzz://claude:frontend@*:*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve string: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Peers) != 1 {
|
||||
t.Errorf("expected 1 peer, got %d", len(result.Peers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverCaching(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{}, WithCacheTTL(1*time.Second))
|
||||
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
resolver.RegisterPeer(peerID, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Status: "ready",
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
uri := "bzzz://claude:frontend@*:*"
|
||||
|
||||
// First resolution should hit the resolver
|
||||
result1, err := resolver.ResolveString(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve: %v", err)
|
||||
}
|
||||
|
||||
// Second resolution should hit the cache
|
||||
result2, err := resolver.ResolveString(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve: %v", err)
|
||||
}
|
||||
|
||||
// Results should be identical (from cache)
|
||||
if result1.ResolvedAt != result2.ResolvedAt {
|
||||
// This is expected behavior - cache should return same timestamp
|
||||
}
|
||||
|
||||
// Wait for cache to expire
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Third resolution should miss cache and create new result
|
||||
result3, err := resolver.ResolveString(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve: %v", err)
|
||||
}
|
||||
|
||||
if result3.ResolvedAt.Before(result1.ResolvedAt.Add(1 * time.Second)) {
|
||||
t.Error("cache should have expired and created new result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionStrategies(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
// Register peers with different priorities
|
||||
peerID1 := test.RandPeerIDFatal(t)
|
||||
peerID2 := test.RandPeerIDFatal(t)
|
||||
|
||||
resolver.RegisterPeer(peerID1, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Status: "ready",
|
||||
})
|
||||
|
||||
resolver.RegisterPeer(peerID2, &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Status: "busy",
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
uri, _ := ParseBzzzURI("bzzz://claude:frontend@*:*")
|
||||
|
||||
// Test different strategies
|
||||
strategies := []ResolutionStrategy{
|
||||
StrategyBestMatch,
|
||||
StrategyPriority,
|
||||
StrategyLoadBalance,
|
||||
StrategyExact,
|
||||
}
|
||||
|
||||
for _, strategy := range strategies {
|
||||
result, err := resolver.Resolve(ctx, uri, strategy)
|
||||
if err != nil {
|
||||
t.Errorf("failed to resolve with strategy %s: %v", strategy, err)
|
||||
}
|
||||
|
||||
if len(result.Peers) == 0 {
|
||||
t.Errorf("no peers found with strategy %s", strategy)
|
||||
}
|
||||
|
||||
if result.Strategy != string(strategy) {
|
||||
t.Errorf("strategy not recorded correctly: expected %s, got %s", strategy, result.Strategy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerMatching(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
capability := &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Capabilities: []string{"react", "javascript"},
|
||||
Status: "ready",
|
||||
Metadata: map[string]string{"project": "chorus"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uri *BzzzURI
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
uri: &BzzzURI{Agent: "claude", Role: "frontend", Project: "chorus", Task: "react"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard agent",
|
||||
uri: &BzzzURI{Agent: "*", Role: "frontend", Project: "chorus", Task: "react"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "capability match",
|
||||
uri: &BzzzURI{Agent: "claude", Role: "frontend", Project: "*", Task: "javascript"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match - wrong agent",
|
||||
uri: &BzzzURI{Agent: "gpt", Role: "frontend", Project: "chorus", Task: "react"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no match - wrong role",
|
||||
uri: &BzzzURI{Agent: "claude", Role: "backend", Project: "chorus", Task: "react"},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := resolver.peerMatches(capability, tt.uri)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerCapability(t *testing.T) {
|
||||
resolver := NewResolver(&mockPeerstore{})
|
||||
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
capability := &PeerCapability{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
}
|
||||
|
||||
// Test before registration
|
||||
_, exists := resolver.GetPeerCapability(peerID)
|
||||
if exists {
|
||||
t.Error("peer should not exist before registration")
|
||||
}
|
||||
|
||||
// Register and test
|
||||
resolver.RegisterPeer(peerID, capability)
|
||||
|
||||
retrieved, exists := resolver.GetPeerCapability(peerID)
|
||||
if !exists {
|
||||
t.Error("peer should exist after registration")
|
||||
}
|
||||
|
||||
if retrieved.Agent != capability.Agent {
|
||||
t.Errorf("expected agent %s, got %s", capability.Agent, retrieved.Agent)
|
||||
}
|
||||
}
|
||||
|
||||
// Mock peerstore implementation for testing
|
||||
type mockPeerstore struct{}
|
||||
|
||||
func (m *mockPeerstore) PeerInfo(peer.ID) peer.AddrInfo { return peer.AddrInfo{} }
|
||||
func (m *mockPeerstore) Peers() peer.IDSlice { return nil }
|
||||
func (m *mockPeerstore) Addrs(peer.ID) []peerstore.Multiaddr { return nil }
|
||||
func (m *mockPeerstore) AddrStream(context.Context, peer.ID) <-chan peerstore.Multiaddr { return nil }
|
||||
func (m *mockPeerstore) SetAddr(peer.ID, peerstore.Multiaddr, time.Duration) {}
|
||||
func (m *mockPeerstore) SetAddrs(peer.ID, []peerstore.Multiaddr, time.Duration) {}
|
||||
func (m *mockPeerstore) UpdateAddrs(peer.ID, time.Duration, time.Duration) {}
|
||||
func (m *mockPeerstore) ClearAddrs(peer.ID) {}
|
||||
func (m *mockPeerstore) PeersWithAddrs() peer.IDSlice { return nil }
|
||||
func (m *mockPeerstore) PubKey(peer.ID) peerstore.PubKey { return nil }
|
||||
func (m *mockPeerstore) SetPubKey(peer.ID, peerstore.PubKey) error { return nil }
|
||||
func (m *mockPeerstore) PrivKey(peer.ID) peerstore.PrivKey { return nil }
|
||||
func (m *mockPeerstore) SetPrivKey(peer.ID, peerstore.PrivKey) error { return nil }
|
||||
func (m *mockPeerstore) Get(peer.ID, string) (interface{}, error) { return nil, nil }
|
||||
func (m *mockPeerstore) Put(peer.ID, string, interface{}) error { return nil }
|
||||
func (m *mockPeerstore) GetProtocols(peer.ID) ([]peerstore.Protocol, error) { return nil, nil }
|
||||
func (m *mockPeerstore) SetProtocols(peer.ID, ...peerstore.Protocol) error { return nil }
|
||||
func (m *mockPeerstore) SupportsProtocols(peer.ID, ...peerstore.Protocol) ([]peerstore.Protocol, error) { return nil, nil }
|
||||
func (m *mockPeerstore) RemovePeer(peer.ID) {}
|
||||
func (m *mockPeerstore) Close() error { return nil }
|
||||
326
pkg/protocol/uri.go
Normal file
326
pkg/protocol/uri.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BzzzURI represents a parsed bzzz:// URI with semantic addressing
|
||||
// Grammar: bzzz://[agent]:[role]@[project]:[task]/[path][?query][#fragment]
|
||||
type BzzzURI struct {
|
||||
// Core addressing components
|
||||
Agent string // Agent identifier (e.g., "claude", "any", "*")
|
||||
Role string // Agent role (e.g., "frontend", "backend", "architect")
|
||||
Project string // Project context (e.g., "chorus", "bzzz")
|
||||
Task string // Task identifier (e.g., "implement", "review", "test", "*")
|
||||
|
||||
// Resource path
|
||||
Path string // Resource path (e.g., "/src/main.go", "/docs/api.md")
|
||||
|
||||
// Standard URI components
|
||||
Query string // Query parameters
|
||||
Fragment string // Fragment identifier
|
||||
|
||||
// Original raw URI string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// URI grammar constants
|
||||
const (
|
||||
BzzzScheme = "bzzz"
|
||||
|
||||
// Special identifiers
|
||||
AnyAgent = "any"
|
||||
AnyRole = "any"
|
||||
AnyProject = "any"
|
||||
AnyTask = "any"
|
||||
Wildcard = "*"
|
||||
)
|
||||
|
||||
// Validation patterns
|
||||
var (
|
||||
// Component validation patterns
|
||||
agentPattern = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$|^\*$|^any$`)
|
||||
rolePattern = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$|^\*$|^any$`)
|
||||
projectPattern = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$|^\*$|^any$`)
|
||||
taskPattern = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$|^\*$|^any$`)
|
||||
pathPattern = regexp.MustCompile(`^/[a-zA-Z0-9\-_/\.]*$|^$`)
|
||||
|
||||
// Full URI pattern for validation
|
||||
bzzzURIPattern = regexp.MustCompile(`^bzzz://([a-zA-Z0-9\-_*]|any):([a-zA-Z0-9\-_*]|any)@([a-zA-Z0-9\-_*]|any):([a-zA-Z0-9\-_*]|any)(/[a-zA-Z0-9\-_/\.]*)?(\?[^#]*)?(\#.*)?$`)
|
||||
)
|
||||
|
||||
// ParseBzzzURI parses a bzzz:// URI string into a BzzzURI struct
|
||||
func ParseBzzzURI(uri string) (*BzzzURI, error) {
|
||||
if uri == "" {
|
||||
return nil, fmt.Errorf("empty URI")
|
||||
}
|
||||
|
||||
// Basic scheme validation
|
||||
if !strings.HasPrefix(uri, BzzzScheme+"://") {
|
||||
return nil, fmt.Errorf("invalid scheme: expected '%s'", BzzzScheme)
|
||||
}
|
||||
|
||||
// Use Go's standard URL parser for basic parsing
|
||||
parsedURL, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse URI: %w", err)
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != BzzzScheme {
|
||||
return nil, fmt.Errorf("invalid scheme: expected '%s', got '%s'", BzzzScheme, parsedURL.Scheme)
|
||||
}
|
||||
|
||||
// Parse the authority part (user:pass@host:port becomes agent:role@project:task)
|
||||
userInfo := parsedURL.User
|
||||
if userInfo == nil {
|
||||
return nil, fmt.Errorf("missing agent:role information")
|
||||
}
|
||||
|
||||
username := userInfo.Username()
|
||||
password, hasPassword := userInfo.Password()
|
||||
if !hasPassword {
|
||||
return nil, fmt.Errorf("missing role information")
|
||||
}
|
||||
|
||||
agent := username
|
||||
role := password
|
||||
|
||||
// Parse host:port as project:task
|
||||
hostPort := parsedURL.Host
|
||||
if hostPort == "" {
|
||||
return nil, fmt.Errorf("missing project:task information")
|
||||
}
|
||||
|
||||
// Split host:port to get project:task
|
||||
parts := strings.Split(hostPort, ":")
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid project:task format: expected 'project:task'")
|
||||
}
|
||||
|
||||
project := parts[0]
|
||||
task := parts[1]
|
||||
|
||||
// Create BzzzURI instance
|
||||
bzzzURI := &BzzzURI{
|
||||
Agent: agent,
|
||||
Role: role,
|
||||
Project: project,
|
||||
Task: task,
|
||||
Path: parsedURL.Path,
|
||||
Query: parsedURL.RawQuery,
|
||||
Fragment: parsedURL.Fragment,
|
||||
Raw: uri,
|
||||
}
|
||||
|
||||
// Validate components
|
||||
if err := bzzzURI.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
return bzzzURI, nil
|
||||
}
|
||||
|
||||
// Validate validates all components of the BzzzURI
|
||||
func (u *BzzzURI) Validate() error {
|
||||
// Validate agent
|
||||
if u.Agent == "" {
|
||||
return fmt.Errorf("agent cannot be empty")
|
||||
}
|
||||
if !agentPattern.MatchString(u.Agent) {
|
||||
return fmt.Errorf("invalid agent format: '%s'", u.Agent)
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if u.Role == "" {
|
||||
return fmt.Errorf("role cannot be empty")
|
||||
}
|
||||
if !rolePattern.MatchString(u.Role) {
|
||||
return fmt.Errorf("invalid role format: '%s'", u.Role)
|
||||
}
|
||||
|
||||
// Validate project
|
||||
if u.Project == "" {
|
||||
return fmt.Errorf("project cannot be empty")
|
||||
}
|
||||
if !projectPattern.MatchString(u.Project) {
|
||||
return fmt.Errorf("invalid project format: '%s'", u.Project)
|
||||
}
|
||||
|
||||
// Validate task
|
||||
if u.Task == "" {
|
||||
return fmt.Errorf("task cannot be empty")
|
||||
}
|
||||
if !taskPattern.MatchString(u.Task) {
|
||||
return fmt.Errorf("invalid task format: '%s'", u.Task)
|
||||
}
|
||||
|
||||
// Validate path (optional)
|
||||
if u.Path != "" && !pathPattern.MatchString(u.Path) {
|
||||
return fmt.Errorf("invalid path format: '%s'", u.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns the canonical string representation of the BzzzURI
|
||||
func (u *BzzzURI) String() string {
|
||||
uri := fmt.Sprintf("%s://%s:%s@%s:%s", BzzzScheme, u.Agent, u.Role, u.Project, u.Task)
|
||||
|
||||
if u.Path != "" {
|
||||
uri += u.Path
|
||||
}
|
||||
|
||||
if u.Query != "" {
|
||||
uri += "?" + u.Query
|
||||
}
|
||||
|
||||
if u.Fragment != "" {
|
||||
uri += "#" + u.Fragment
|
||||
}
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
// Normalize normalizes the URI components for consistent addressing
|
||||
func (u *BzzzURI) Normalize() {
|
||||
// Convert empty wildcards to standard wildcard
|
||||
if u.Agent == "" {
|
||||
u.Agent = Wildcard
|
||||
}
|
||||
if u.Role == "" {
|
||||
u.Role = Wildcard
|
||||
}
|
||||
if u.Project == "" {
|
||||
u.Project = Wildcard
|
||||
}
|
||||
if u.Task == "" {
|
||||
u.Task = Wildcard
|
||||
}
|
||||
|
||||
// Normalize to lowercase for consistency
|
||||
u.Agent = strings.ToLower(u.Agent)
|
||||
u.Role = strings.ToLower(u.Role)
|
||||
u.Project = strings.ToLower(u.Project)
|
||||
u.Task = strings.ToLower(u.Task)
|
||||
|
||||
// Clean path
|
||||
if u.Path != "" && !strings.HasPrefix(u.Path, "/") {
|
||||
u.Path = "/" + u.Path
|
||||
}
|
||||
}
|
||||
|
||||
// IsWildcard checks if a component is a wildcard or "any"
|
||||
func IsWildcard(component string) bool {
|
||||
return component == Wildcard || component == AnyAgent || component == AnyRole ||
|
||||
component == AnyProject || component == AnyTask
|
||||
}
|
||||
|
||||
// Matches checks if this URI matches another URI (with wildcard support)
|
||||
func (u *BzzzURI) Matches(other *BzzzURI) bool {
|
||||
if other == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check each component with wildcard support
|
||||
if !componentMatches(u.Agent, other.Agent) {
|
||||
return false
|
||||
}
|
||||
if !componentMatches(u.Role, other.Role) {
|
||||
return false
|
||||
}
|
||||
if !componentMatches(u.Project, other.Project) {
|
||||
return false
|
||||
}
|
||||
if !componentMatches(u.Task, other.Task) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Path matching (exact or wildcard)
|
||||
if u.Path != "" && other.Path != "" && u.Path != other.Path {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// componentMatches checks if two components match (with wildcard support)
|
||||
func componentMatches(a, b string) bool {
|
||||
// Exact match
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
|
||||
// Wildcard matching
|
||||
if IsWildcard(a) || IsWildcard(b) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSelectorPriority returns a priority score for URI matching (higher = more specific)
|
||||
func (u *BzzzURI) GetSelectorPriority() int {
|
||||
priority := 0
|
||||
|
||||
// More specific components get higher priority
|
||||
if !IsWildcard(u.Agent) {
|
||||
priority += 8
|
||||
}
|
||||
if !IsWildcard(u.Role) {
|
||||
priority += 4
|
||||
}
|
||||
if !IsWildcard(u.Project) {
|
||||
priority += 2
|
||||
}
|
||||
if !IsWildcard(u.Task) {
|
||||
priority += 1
|
||||
}
|
||||
|
||||
// Path specificity adds priority
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
priority += 1
|
||||
}
|
||||
|
||||
return priority
|
||||
}
|
||||
|
||||
// ToAddress returns a simplified address representation for P2P routing
|
||||
func (u *BzzzURI) ToAddress() string {
|
||||
return fmt.Sprintf("%s:%s@%s:%s", u.Agent, u.Role, u.Project, u.Task)
|
||||
}
|
||||
|
||||
// ValidateBzzzURIString validates a bzzz:// URI string without parsing
|
||||
func ValidateBzzzURIString(uri string) error {
|
||||
if uri == "" {
|
||||
return fmt.Errorf("empty URI")
|
||||
}
|
||||
|
||||
if !bzzzURIPattern.MatchString(uri) {
|
||||
return fmt.Errorf("invalid bzzz:// URI format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewBzzzURI creates a new BzzzURI with the given components
|
||||
func NewBzzzURI(agent, role, project, task, path string) *BzzzURI {
|
||||
uri := &BzzzURI{
|
||||
Agent: agent,
|
||||
Role: role,
|
||||
Project: project,
|
||||
Task: task,
|
||||
Path: path,
|
||||
}
|
||||
uri.Normalize()
|
||||
return uri
|
||||
}
|
||||
|
||||
// ParseAddress parses a simplified address format (agent:role@project:task)
|
||||
func ParseAddress(addr string) (*BzzzURI, error) {
|
||||
// Convert simplified address to full URI
|
||||
fullURI := BzzzScheme + "://" + addr
|
||||
return ParseBzzzURI(fullURI)
|
||||
}
|
||||
509
pkg/protocol/uri_test.go
Normal file
509
pkg/protocol/uri_test.go
Normal file
@@ -0,0 +1,509 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBzzzURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
expectError bool
|
||||
expected *BzzzURI
|
||||
}{
|
||||
{
|
||||
name: "valid basic URI",
|
||||
uri: "bzzz://claude:frontend@chorus:implement/src/main.go",
|
||||
expected: &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
Path: "/src/main.go",
|
||||
Raw: "bzzz://claude:frontend@chorus:implement/src/main.go",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "URI with wildcards",
|
||||
uri: "bzzz://any:*@*:test",
|
||||
expected: &BzzzURI{
|
||||
Agent: "any",
|
||||
Role: "*",
|
||||
Project: "*",
|
||||
Task: "test",
|
||||
Raw: "bzzz://any:*@*:test",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "URI with query and fragment",
|
||||
uri: "bzzz://claude:backend@bzzz:debug/api/handler.go?type=error#line123",
|
||||
expected: &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "backend",
|
||||
Project: "bzzz",
|
||||
Task: "debug",
|
||||
Path: "/api/handler.go",
|
||||
Query: "type=error",
|
||||
Fragment: "line123",
|
||||
Raw: "bzzz://claude:backend@bzzz:debug/api/handler.go?type=error#line123",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "URI without path",
|
||||
uri: "bzzz://any:architect@project:review",
|
||||
expected: &BzzzURI{
|
||||
Agent: "any",
|
||||
Role: "architect",
|
||||
Project: "project",
|
||||
Task: "review",
|
||||
Raw: "bzzz://any:architect@project:review",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
uri: "http://claude:frontend@chorus:implement",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "missing role",
|
||||
uri: "bzzz://claude@chorus:implement",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "missing task",
|
||||
uri: "bzzz://claude:frontend@chorus",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty URI",
|
||||
uri: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
uri: "bzzz://invalid",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseBzzzURI(tt.uri)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Errorf("result is nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Compare components
|
||||
if result.Agent != tt.expected.Agent {
|
||||
t.Errorf("Agent: expected %s, got %s", tt.expected.Agent, result.Agent)
|
||||
}
|
||||
if result.Role != tt.expected.Role {
|
||||
t.Errorf("Role: expected %s, got %s", tt.expected.Role, result.Role)
|
||||
}
|
||||
if result.Project != tt.expected.Project {
|
||||
t.Errorf("Project: expected %s, got %s", tt.expected.Project, result.Project)
|
||||
}
|
||||
if result.Task != tt.expected.Task {
|
||||
t.Errorf("Task: expected %s, got %s", tt.expected.Task, result.Task)
|
||||
}
|
||||
if result.Path != tt.expected.Path {
|
||||
t.Errorf("Path: expected %s, got %s", tt.expected.Path, result.Path)
|
||||
}
|
||||
if result.Query != tt.expected.Query {
|
||||
t.Errorf("Query: expected %s, got %s", tt.expected.Query, result.Query)
|
||||
}
|
||||
if result.Fragment != tt.expected.Fragment {
|
||||
t.Errorf("Fragment: expected %s, got %s", tt.expected.Fragment, result.Fragment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBzzzURIValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri *BzzzURI
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid URI",
|
||||
uri: &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
Path: "/src/main.go",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty agent",
|
||||
uri: &BzzzURI{
|
||||
Agent: "",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid agent format",
|
||||
uri: &BzzzURI{
|
||||
Agent: "invalid@agent",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard components",
|
||||
uri: &BzzzURI{
|
||||
Agent: "*",
|
||||
Role: "any",
|
||||
Project: "*",
|
||||
Task: "*",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid path",
|
||||
uri: &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
Path: "invalid-path", // Should start with /
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.uri.Validate()
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBzzzURINormalize(t *testing.T) {
|
||||
uri := &BzzzURI{
|
||||
Agent: "Claude",
|
||||
Role: "Frontend",
|
||||
Project: "CHORUS",
|
||||
Task: "Implement",
|
||||
Path: "src/main.go", // Missing leading slash
|
||||
}
|
||||
|
||||
uri.Normalize()
|
||||
|
||||
expected := &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
Path: "/src/main.go",
|
||||
}
|
||||
|
||||
if uri.Agent != expected.Agent {
|
||||
t.Errorf("Agent: expected %s, got %s", expected.Agent, uri.Agent)
|
||||
}
|
||||
if uri.Role != expected.Role {
|
||||
t.Errorf("Role: expected %s, got %s", expected.Role, uri.Role)
|
||||
}
|
||||
if uri.Project != expected.Project {
|
||||
t.Errorf("Project: expected %s, got %s", expected.Project, uri.Project)
|
||||
}
|
||||
if uri.Task != expected.Task {
|
||||
t.Errorf("Task: expected %s, got %s", expected.Task, uri.Task)
|
||||
}
|
||||
if uri.Path != expected.Path {
|
||||
t.Errorf("Path: expected %s, got %s", expected.Path, uri.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBzzzURIMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri1 *BzzzURI
|
||||
uri2 *BzzzURI
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
uri1: &BzzzURI{Agent: "claude", Role: "frontend", Project: "chorus", Task: "implement"},
|
||||
uri2: &BzzzURI{Agent: "claude", Role: "frontend", Project: "chorus", Task: "implement"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard agent match",
|
||||
uri1: &BzzzURI{Agent: "*", Role: "frontend", Project: "chorus", Task: "implement"},
|
||||
uri2: &BzzzURI{Agent: "claude", Role: "frontend", Project: "chorus", Task: "implement"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "any role match",
|
||||
uri1: &BzzzURI{Agent: "claude", Role: "any", Project: "chorus", Task: "implement"},
|
||||
uri2: &BzzzURI{Agent: "claude", Role: "frontend", Project: "chorus", Task: "implement"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
uri1: &BzzzURI{Agent: "claude", Role: "backend", Project: "chorus", Task: "implement"},
|
||||
uri2: &BzzzURI{Agent: "claude", Role: "frontend", Project: "chorus", Task: "implement"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil comparison",
|
||||
uri1: &BzzzURI{Agent: "claude", Role: "frontend", Project: "chorus", Task: "implement"},
|
||||
uri2: nil,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.uri1.Matches(tt.uri2)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBzzzURIString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri *BzzzURI
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic URI",
|
||||
uri: &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
Path: "/src/main.go",
|
||||
},
|
||||
expected: "bzzz://claude:frontend@chorus:implement/src/main.go",
|
||||
},
|
||||
{
|
||||
name: "URI with query and fragment",
|
||||
uri: &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "backend",
|
||||
Project: "bzzz",
|
||||
Task: "debug",
|
||||
Path: "/api/handler.go",
|
||||
Query: "type=error",
|
||||
Fragment: "line123",
|
||||
},
|
||||
expected: "bzzz://claude:backend@bzzz:debug/api/handler.go?type=error#line123",
|
||||
},
|
||||
{
|
||||
name: "URI without path",
|
||||
uri: &BzzzURI{
|
||||
Agent: "any",
|
||||
Role: "architect",
|
||||
Project: "project",
|
||||
Task: "review",
|
||||
},
|
||||
expected: "bzzz://any:architect@project:review",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.uri.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSelectorPriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri *BzzzURI
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "all specific",
|
||||
uri: &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
Path: "/src/main.go",
|
||||
},
|
||||
expected: 8 + 4 + 2 + 1 + 1, // All components + path
|
||||
},
|
||||
{
|
||||
name: "some wildcards",
|
||||
uri: &BzzzURI{
|
||||
Agent: "*",
|
||||
Role: "frontend",
|
||||
Project: "*",
|
||||
Task: "implement",
|
||||
},
|
||||
expected: 4 + 1, // Role + Task
|
||||
},
|
||||
{
|
||||
name: "all wildcards",
|
||||
uri: &BzzzURI{
|
||||
Agent: "*",
|
||||
Role: "any",
|
||||
Project: "*",
|
||||
Task: "*",
|
||||
},
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.uri.GetSelectorPriority()
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %d, got %d", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
expectError bool
|
||||
expected *BzzzURI
|
||||
}{
|
||||
{
|
||||
name: "valid address",
|
||||
addr: "claude:frontend@chorus:implement",
|
||||
expected: &BzzzURI{
|
||||
Agent: "claude",
|
||||
Role: "frontend",
|
||||
Project: "chorus",
|
||||
Task: "implement",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid address",
|
||||
addr: "invalid-format",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseAddress(tt.addr)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Agent != tt.expected.Agent {
|
||||
t.Errorf("Agent: expected %s, got %s", tt.expected.Agent, result.Agent)
|
||||
}
|
||||
if result.Role != tt.expected.Role {
|
||||
t.Errorf("Role: expected %s, got %s", tt.expected.Role, result.Role)
|
||||
}
|
||||
if result.Project != tt.expected.Project {
|
||||
t.Errorf("Project: expected %s, got %s", tt.expected.Project, result.Project)
|
||||
}
|
||||
if result.Task != tt.expected.Task {
|
||||
t.Errorf("Task: expected %s, got %s", tt.expected.Task, result.Task)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWildcard(t *testing.T) {
|
||||
tests := []struct {
|
||||
component string
|
||||
expected bool
|
||||
}{
|
||||
{"*", true},
|
||||
{"any", true},
|
||||
{"claude", false},
|
||||
{"frontend", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.component, func(t *testing.T) {
|
||||
result := IsWildcard(tt.component)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBzzzURIString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid URI",
|
||||
uri: "bzzz://claude:frontend@chorus:implement/src/main.go",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
uri: "http://claude:frontend@chorus:implement",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty URI",
|
||||
uri: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateBzzzURIString(tt.uri)
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user