diff --git a/internal/mail/resolve.go b/internal/mail/resolve.go new file mode 100644 index 00000000..61894516 --- /dev/null +++ b/internal/mail/resolve.go @@ -0,0 +1,368 @@ +// Package mail provides address resolution for beads-native messaging. +// This module implements the resolution order: +// 1. Contains '/' → agent address or pattern +// 2. Starts with '@' → special pattern (@town, @crew, @rig/X, @role/X) +// 3. Otherwise → lookup by name: group → queue → channel +// 4. If conflict, require prefix (group:X, queue:X, channel:X) +package mail + +import ( + "fmt" + "strings" + + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/config" +) + +// RecipientType indicates the type of resolved recipient. +type RecipientType string + +const ( + RecipientAgent RecipientType = "agent" // Direct to agent(s) + RecipientQueue RecipientType = "queue" // Single message, workers claim + RecipientChannel RecipientType = "channel" // Broadcast, retained +) + +// Recipient represents a resolved message recipient. +type Recipient struct { + Address string // The resolved address (e.g., "gastown/crew/max") + Type RecipientType // Type of recipient (agent, queue, channel) + OriginalName string // Original name before resolution (for queues/channels) +} + +// Resolver handles address resolution for beads-native messaging. +type Resolver struct { + beads *beads.Beads + townRoot string +} + +// NewResolver creates a new address resolver. +func NewResolver(b *beads.Beads, townRoot string) *Resolver { + return &Resolver{ + beads: b, + townRoot: townRoot, + } +} + +// Resolve resolves an address to a list of recipients. +// Resolution order: +// 1. Contains '/' → agent address or pattern (direct delivery) +// 2. Starts with '@' → special pattern (@town, @crew, etc.) +// 3. Starts with explicit prefix → use that type (group:, queue:, channel:) +// 4. Otherwise → lookup by name: group → queue → channel +func (r *Resolver) Resolve(address string) ([]Recipient, error) { + // 1. Explicit prefix takes precedence + if strings.HasPrefix(address, "group:") { + name := strings.TrimPrefix(address, "group:") + return r.resolveBeadsGroup(name) + } + if strings.HasPrefix(address, "queue:") { + name := strings.TrimPrefix(address, "queue:") + return r.resolveQueue(name) + } + if strings.HasPrefix(address, "channel:") { + name := strings.TrimPrefix(address, "channel:") + return r.resolveChannel(name) + } + + // Legacy prefixes (list:, announce:) - pass through + if strings.HasPrefix(address, "list:") || strings.HasPrefix(address, "announce:") { + // These are handled by existing router logic + return []Recipient{{Address: address, Type: RecipientAgent}}, nil + } + + // 2. Contains '/' → agent address or pattern + if strings.Contains(address, "/") { + return r.resolveAgentAddress(address) + } + + // 3. Starts with '@' → special pattern + if strings.HasPrefix(address, "@") { + return r.resolveAtPattern(address) + } + + // 4. Name lookup: group → queue → channel + return r.resolveByName(address) +} + +// resolveAgentAddress handles addresses containing '/'. +// These are either direct addresses or patterns. +func (r *Resolver) resolveAgentAddress(address string) ([]Recipient, error) { + // Check for wildcard patterns + if strings.Contains(address, "*") { + return r.resolvePattern(address) + } + + // Direct address - single recipient + return []Recipient{{ + Address: address, + Type: RecipientAgent, + }}, nil +} + +// resolvePattern expands a wildcard pattern to matching agents. +// Patterns like "*/witness" or "gastown/*" are expanded. +func (r *Resolver) resolvePattern(pattern string) ([]Recipient, error) { + if r.beads == nil { + return nil, fmt.Errorf("beads not available for pattern resolution") + } + + // Get all agent beads + agents, err := r.beads.ListAgentBeads() + if err != nil { + return nil, fmt.Errorf("listing agents: %w", err) + } + + var recipients []Recipient + for id := range agents { + // Convert bead ID to address and check match + addr := agentBeadIDToAddress(id) + if addr != "" && matchPattern(pattern, addr) { + recipients = append(recipients, Recipient{ + Address: addr, + Type: RecipientAgent, + }) + } + } + + if len(recipients) == 0 { + return nil, fmt.Errorf("no agents match pattern: %s", pattern) + } + + return recipients, nil +} + +// resolveAtPattern handles @-prefixed patterns. +// These include @town, @crew, @rig/X, @role/X, @overseer. +func (r *Resolver) resolveAtPattern(address string) ([]Recipient, error) { + // First check if this is a beads-native group (if beads available) + if r.beads != nil { + groupName := strings.TrimPrefix(address, "@") + issue, fields, err := r.beads.LookupGroupByName(groupName) + if err != nil { + return nil, err + } + if issue != nil && fields != nil { + // Found a beads-native group - expand its members + return r.expandGroupMembers(fields) + } + } + + // Fall back to built-in patterns (handled by existing router) + // Return as-is for router to handle + return []Recipient{{Address: address, Type: RecipientAgent}}, nil +} + +// resolveByName looks up a name as group → queue → channel. +// Returns error if name conflicts exist without explicit prefix. +func (r *Resolver) resolveByName(name string) ([]Recipient, error) { + var foundGroup, foundQueue, foundChannel bool + var groupFields *beads.GroupFields + + // Check for beads-native group + if r.beads != nil { + _, fields, err := r.beads.LookupGroupByName(name) + if err != nil { + return nil, err + } + if fields != nil { + foundGroup = true + groupFields = fields + } + } + + // Check for queue in config + if r.townRoot != "" { + cfg, err := config.LoadMessagingConfig(config.MessagingConfigPath(r.townRoot)) + if err == nil && cfg != nil { + if _, ok := cfg.Queues[name]; ok { + foundQueue = true + } + if _, ok := cfg.Announces[name]; ok { + foundChannel = true + } + } + } + + // Count conflicts + conflictCount := 0 + if foundGroup { + conflictCount++ + } + if foundQueue { + conflictCount++ + } + if foundChannel { + conflictCount++ + } + + if conflictCount == 0 { + return nil, fmt.Errorf("unknown address: %s (not a group, queue, or channel)", name) + } + + if conflictCount > 1 { + var types []string + if foundGroup { + types = append(types, "group:"+name) + } + if foundQueue { + types = append(types, "queue:"+name) + } + if foundChannel { + types = append(types, "channel:"+name) + } + return nil, fmt.Errorf("ambiguous address %q: matches multiple types. Use explicit prefix: %s", + name, strings.Join(types, ", ")) + } + + // Single match - resolve it + if foundGroup { + return r.expandGroupMembers(groupFields) + } + if foundQueue { + return r.resolveQueue(name) + } + return r.resolveChannel(name) +} + +// resolveBeadsGroup resolves a beads-native group by name. +func (r *Resolver) resolveBeadsGroup(name string) ([]Recipient, error) { + if r.beads == nil { + return nil, fmt.Errorf("beads not available") + } + + _, fields, err := r.beads.LookupGroupByName(name) + if err != nil { + return nil, err + } + if fields == nil { + return nil, fmt.Errorf("group not found: %s", name) + } + + return r.expandGroupMembers(fields) +} + +// expandGroupMembers expands a group's members to recipients. +// Handles nested groups and patterns recursively. +func (r *Resolver) expandGroupMembers(fields *beads.GroupFields) ([]Recipient, error) { + return r.expandGroupMembersWithVisited(fields, make(map[string]bool)) +} + +// expandGroupMembersWithVisited expands group members with cycle detection. +func (r *Resolver) expandGroupMembersWithVisited(fields *beads.GroupFields, visited map[string]bool) ([]Recipient, error) { + if fields == nil { + return nil, nil + } + + // Mark this group as visited for cycle detection + if fields.Name != "" { + if visited[fields.Name] { + // Cycle detected - skip silently (as per design: "silent skip with warning") + return nil, nil + } + visited[fields.Name] = true + } + + seen := make(map[string]bool) + var recipients []Recipient + + for _, member := range fields.Members { + // Recursively resolve each member + resolved, err := r.resolveMemberWithVisited(member, visited) + if err != nil { + // Log warning but continue with other members + continue + } + + for _, rec := range resolved { + // Deduplicate + if !seen[rec.Address] { + seen[rec.Address] = true + recipients = append(recipients, rec) + } + } + } + + return recipients, nil +} + +// resolveMemberWithVisited resolves a single group member with cycle detection. +func (r *Resolver) resolveMemberWithVisited(member string, visited map[string]bool) ([]Recipient, error) { + // Check if this is a nested group reference + if r.beads != nil && !strings.Contains(member, "/") && !strings.HasPrefix(member, "@") { + _, fields, err := r.beads.LookupGroupByName(member) + if err == nil && fields != nil { + return r.expandGroupMembersWithVisited(fields, visited) + } + } + + // Otherwise resolve normally + return r.Resolve(member) +} + +// resolveQueue returns a queue recipient. +func (r *Resolver) resolveQueue(name string) ([]Recipient, error) { + return []Recipient{{ + Address: "queue:" + name, + Type: RecipientQueue, + OriginalName: name, + }}, nil +} + +// resolveChannel returns a channel recipient. +func (r *Resolver) resolveChannel(name string) ([]Recipient, error) { + return []Recipient{{ + Address: "channel:" + name, + Type: RecipientChannel, + OriginalName: name, + }}, nil +} + +// agentBeadIDToAddress converts an agent bead ID to a mail address. +// E.g., "gt-gastown-crew-max" → "gastown/crew/max" +func agentBeadIDToAddress(id string) string { + if !strings.HasPrefix(id, "gt-") { + return "" + } + + rest := strings.TrimPrefix(id, "gt-") + parts := strings.Split(rest, "-") + + switch len(parts) { + case 1: + // Town-level: gt-mayor → mayor/ + return parts[0] + "/" + case 2: + // Rig singleton: gt-gastown-witness → gastown/witness + return parts[0] + "/" + parts[1] + default: + // Rig named agent: gt-gastown-crew-max → gastown/crew/max + if len(parts) >= 3 { + name := strings.Join(parts[2:], "-") + return parts[0] + "/" + parts[1] + "/" + name + } + return "" + } +} + +// matchPattern checks if an address matches a wildcard pattern. +// '*' matches any single path segment (no slashes). +func matchPattern(pattern, address string) bool { + patternParts := strings.Split(pattern, "/") + addressParts := strings.Split(address, "/") + + if len(patternParts) != len(addressParts) { + return false + } + + for i, p := range patternParts { + if p == "*" { + continue // Wildcard matches anything + } + if p != addressParts[i] { + return false + } + } + + return true +} diff --git a/internal/mail/resolve_test.go b/internal/mail/resolve_test.go new file mode 100644 index 00000000..1b1b175b --- /dev/null +++ b/internal/mail/resolve_test.go @@ -0,0 +1,162 @@ +package mail + +import ( + "testing" +) + +func TestMatchPattern(t *testing.T) { + tests := []struct { + pattern string + address string + want bool + }{ + // Exact matches + {"gastown/witness", "gastown/witness", true}, + {"mayor/", "mayor/", true}, + + // Wildcard matches + {"*/witness", "gastown/witness", true}, + {"*/witness", "beads/witness", true}, + {"gastown/*", "gastown/witness", true}, + {"gastown/*", "gastown/refinery", true}, + {"gastown/crew/*", "gastown/crew/max", true}, + + // Non-matches + {"*/witness", "gastown/refinery", false}, + {"gastown/*", "beads/witness", false}, + {"gastown/crew/*", "gastown/polecats/Toast", false}, + + // Different path lengths + {"gastown/*", "gastown/crew/max", false}, // * matches single segment + {"gastown/*/*", "gastown/crew/max", true}, // Multiple wildcards + {"*/*", "gastown/witness", true}, // Both wildcards + {"*/*/*", "gastown/crew/max", true}, // Three-level wildcard + } + + for _, tt := range tests { + t.Run(tt.pattern+"_"+tt.address, func(t *testing.T) { + got := matchPattern(tt.pattern, tt.address) + if got != tt.want { + t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.pattern, tt.address, got, tt.want) + } + }) + } +} + +func TestAgentBeadIDToAddress(t *testing.T) { + tests := []struct { + id string + want string + }{ + // Town-level agents + {"gt-mayor", "mayor/"}, + {"gt-deacon", "deacon/"}, + + // Rig singletons + {"gt-gastown-witness", "gastown/witness"}, + {"gt-gastown-refinery", "gastown/refinery"}, + {"gt-beads-witness", "beads/witness"}, + + // Named agents + {"gt-gastown-crew-max", "gastown/crew/max"}, + {"gt-gastown-polecat-Toast", "gastown/polecat/Toast"}, + {"gt-beads-crew-wolf", "beads/crew/wolf"}, + + // Agent with hyphen in name + {"gt-gastown-crew-max-v2", "gastown/crew/max-v2"}, + {"gt-gastown-polecat-my-agent", "gastown/polecat/my-agent"}, + + // Invalid + {"invalid", ""}, + {"not-gt-prefix", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + got := agentBeadIDToAddress(tt.id) + if got != tt.want { + t.Errorf("agentBeadIDToAddress(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestResolverResolve_DirectAddresses(t *testing.T) { + resolver := NewResolver(nil, "") + + tests := []struct { + name string + address string + want RecipientType + wantLen int + }{ + // Direct agent addresses + {"direct agent", "gastown/witness", RecipientAgent, 1}, + {"direct crew", "gastown/crew/max", RecipientAgent, 1}, + {"mayor", "mayor/", RecipientAgent, 1}, + + // Legacy prefixes (pass-through) + {"list prefix", "list:oncall", RecipientAgent, 1}, + {"announce prefix", "announce:alerts", RecipientAgent, 1}, + + // Explicit type prefixes + {"queue prefix", "queue:work", RecipientQueue, 1}, + {"channel prefix", "channel:alerts", RecipientChannel, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolver.Resolve(tt.address) + if err != nil { + t.Fatalf("Resolve(%q) error: %v", tt.address, err) + } + if len(got) != tt.wantLen { + t.Errorf("Resolve(%q) returned %d recipients, want %d", tt.address, len(got), tt.wantLen) + } + if len(got) > 0 && got[0].Type != tt.want { + t.Errorf("Resolve(%q)[0].Type = %v, want %v", tt.address, got[0].Type, tt.want) + } + }) + } +} + +func TestResolverResolve_AtPatterns(t *testing.T) { + // Without beads, @patterns are passed through for existing router + resolver := NewResolver(nil, "") + + tests := []struct { + address string + }{ + {"@town"}, + {"@witnesses"}, + {"@rig/gastown"}, + {"@overseer"}, + } + + for _, tt := range tests { + t.Run(tt.address, func(t *testing.T) { + got, err := resolver.Resolve(tt.address) + if err != nil { + t.Fatalf("Resolve(%q) error: %v", tt.address, err) + } + if len(got) != 1 { + t.Errorf("Resolve(%q) returned %d recipients, want 1", tt.address, len(got)) + } + // Without beads, @patterns pass through unchanged + if got[0].Address != tt.address { + t.Errorf("Resolve(%q) = %q, want pass-through", tt.address, got[0].Address) + } + }) + } +} + +func TestResolverResolve_UnknownName(t *testing.T) { + resolver := NewResolver(nil, "") + + // A bare name without prefix should fail if not found + _, err := resolver.Resolve("unknown-name") + if err == nil { + t.Error("Resolve(\"unknown-name\") should return error for unknown name") + } +}