feat(mail): implement address resolution for beads-native messaging
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>
This commit is contained in:
committed by
Steve Yegge
parent
7164e7a6d2
commit
839fa19e90
368
internal/mail/resolve.go
Normal file
368
internal/mail/resolve.go
Normal file
@@ -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
|
||||
}
|
||||
162
internal/mail/resolve_test.go
Normal file
162
internal/mail/resolve_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user