Add Resolver type with comprehensive address resolution: - Direct agent addresses (contains '/') - Pattern matching (*/witness, gastown/*) - @-prefixed patterns (@town, @crew, @rig/X) - Beads-native groups (gt:group beads) - Name lookup: group → queue → channel - Conflict detection with explicit prefix requirement Implements resolution order per gt-xfqh1e epic design: 1. Contains '/' → agent address or pattern 2. Starts with '@' → special pattern 3. Otherwise → lookup by name with conflict detection Part of gt-xfqh1e.5 (address resolution task). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
369 lines
10 KiB
Go
369 lines
10 KiB
Go
// 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
|
|
}
|