diff --git a/internal/config/loader.go b/internal/config/loader.go index 3813860d..bc590906 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -565,3 +565,111 @@ func expandPath(path string) string { } return path } + +// LoadMessagingConfig loads and validates a messaging configuration file. +func LoadMessagingConfig(path string) (*MessagingConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotFound, path) + } + return nil, fmt.Errorf("reading messaging config: %w", err) + } + + var config MessagingConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("parsing messaging config: %w", err) + } + + if err := validateMessagingConfig(&config); err != nil { + return nil, err + } + + return &config, nil +} + +// SaveMessagingConfig saves a messaging configuration to a file. +func SaveMessagingConfig(path string, config *MessagingConfig) error { + if err := validateMessagingConfig(config); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("encoding messaging config: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing messaging config: %w", err) + } + + return nil +} + +// validateMessagingConfig validates a MessagingConfig. +func validateMessagingConfig(c *MessagingConfig) error { + if c.Version > CurrentMessagingVersion { + return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentMessagingVersion) + } + + // Initialize nil maps + if c.Lists == nil { + c.Lists = make(map[string][]string) + } + if c.Queues == nil { + c.Queues = make(map[string]QueueConfig) + } + if c.Announces == nil { + c.Announces = make(map[string]AnnounceConfig) + } + + // Validate lists have at least one recipient + for name, recipients := range c.Lists { + if len(recipients) == 0 { + return fmt.Errorf("%w: list '%s' has no recipients", ErrMissingField, name) + } + } + + // Validate queues have at least one worker + for name, queue := range c.Queues { + if len(queue.Workers) == 0 { + return fmt.Errorf("%w: queue '%s' has no workers", ErrMissingField, name) + } + if queue.MaxClaims < 0 { + return fmt.Errorf("queue '%s': max_claims must be non-negative", name) + } + } + + // Validate announces have at least one reader + for name, announce := range c.Announces { + if len(announce.Readers) == 0 { + return fmt.Errorf("%w: announce '%s' has no readers", ErrMissingField, name) + } + if announce.RetainCount < 0 { + return fmt.Errorf("announce '%s': retain_count must be non-negative", name) + } + } + + return nil +} + +// MessagingConfigPath returns the standard path for messaging config in a town. +func MessagingConfigPath(townRoot string) string { + return filepath.Join(townRoot, "config", "messaging.json") +} + +// LoadOrCreateMessagingConfig loads the messaging config, creating a default if not found. +func LoadOrCreateMessagingConfig(path string) (*MessagingConfig, error) { + config, err := LoadMessagingConfig(path) + if err != nil { + if errors.Is(err, ErrNotFound) { + return NewMessagingConfig(), nil + } + return nil, err + } + return config, nil +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 8d52d5fd..4388b306 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -549,3 +549,186 @@ func TestLoadAccountsConfigNotFound(t *testing.T) { t.Fatal("expected error for nonexistent file") } } + +func TestMessagingConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config", "messaging.json") + + original := NewMessagingConfig() + original.Lists["oncall"] = []string{"mayor/", "gastown/witness"} + original.Lists["cleanup"] = []string{"gastown/witness", "deacon/"} + original.Queues["work/gastown"] = QueueConfig{ + Workers: []string{"gastown/polecats/*"}, + MaxClaims: 5, + } + original.Announces["alerts"] = AnnounceConfig{ + Readers: []string{"@town"}, + RetainCount: 100, + } + + if err := SaveMessagingConfig(path, original); err != nil { + t.Fatalf("SaveMessagingConfig: %v", err) + } + + loaded, err := LoadMessagingConfig(path) + if err != nil { + t.Fatalf("LoadMessagingConfig: %v", err) + } + + if loaded.Version != CurrentMessagingVersion { + t.Errorf("Version = %d, want %d", loaded.Version, CurrentMessagingVersion) + } + + // Check lists + if len(loaded.Lists) != 2 { + t.Errorf("Lists count = %d, want 2", len(loaded.Lists)) + } + if oncall, ok := loaded.Lists["oncall"]; !ok || len(oncall) != 2 { + t.Error("oncall list not preserved") + } + + // Check queues + if len(loaded.Queues) != 1 { + t.Errorf("Queues count = %d, want 1", len(loaded.Queues)) + } + if q, ok := loaded.Queues["work/gastown"]; !ok || q.MaxClaims != 5 { + t.Error("queue not preserved") + } + + // Check announces + if len(loaded.Announces) != 1 { + t.Errorf("Announces count = %d, want 1", len(loaded.Announces)) + } + if a, ok := loaded.Announces["alerts"]; !ok || a.RetainCount != 100 { + t.Error("announce not preserved") + } +} + +func TestMessagingConfigValidation(t *testing.T) { + tests := []struct { + name string + config *MessagingConfig + wantErr bool + }{ + { + name: "valid empty config", + config: NewMessagingConfig(), + wantErr: false, + }, + { + name: "valid config with lists", + config: &MessagingConfig{ + Version: 1, + Lists: map[string][]string{ + "oncall": {"mayor/", "gastown/witness"}, + }, + }, + wantErr: false, + }, + { + name: "list with no recipients", + config: &MessagingConfig{ + Version: 1, + Lists: map[string][]string{ + "empty": {}, + }, + }, + wantErr: true, + }, + { + name: "queue with no workers", + config: &MessagingConfig{ + Version: 1, + Queues: map[string]QueueConfig{ + "work": {Workers: []string{}}, + }, + }, + wantErr: true, + }, + { + name: "queue with negative max_claims", + config: &MessagingConfig{ + Version: 1, + Queues: map[string]QueueConfig{ + "work": {Workers: []string{"worker/"}, MaxClaims: -1}, + }, + }, + wantErr: true, + }, + { + name: "announce with no readers", + config: &MessagingConfig{ + Version: 1, + Announces: map[string]AnnounceConfig{ + "alerts": {Readers: []string{}}, + }, + }, + wantErr: true, + }, + { + name: "announce with negative retain_count", + config: &MessagingConfig{ + Version: 1, + Announces: map[string]AnnounceConfig{ + "alerts": {Readers: []string{"@town"}, RetainCount: -1}, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMessagingConfig(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("validateMessagingConfig() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestLoadMessagingConfigNotFound(t *testing.T) { + _, err := LoadMessagingConfig("/nonexistent/path.json") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestLoadOrCreateMessagingConfig(t *testing.T) { + // Test creating default when not found + config, err := LoadOrCreateMessagingConfig("/nonexistent/path.json") + if err != nil { + t.Fatalf("LoadOrCreateMessagingConfig: %v", err) + } + if config == nil { + t.Fatal("expected non-nil config") + } + if config.Version != CurrentMessagingVersion { + t.Errorf("Version = %d, want %d", config.Version, CurrentMessagingVersion) + } + + // Test loading existing + dir := t.TempDir() + path := filepath.Join(dir, "messaging.json") + original := NewMessagingConfig() + original.Lists["test"] = []string{"mayor/"} + if err := SaveMessagingConfig(path, original); err != nil { + t.Fatalf("SaveMessagingConfig: %v", err) + } + + loaded, err := LoadOrCreateMessagingConfig(path) + if err != nil { + t.Fatalf("LoadOrCreateMessagingConfig: %v", err) + } + if _, ok := loaded.Lists["test"]; !ok { + t.Error("existing config not loaded") + } +} + +func TestMessagingConfigPath(t *testing.T) { + path := MessagingConfigPath("/home/user/gt") + expected := "/home/user/gt/config/messaging.json" + if path != expected { + t.Errorf("MessagingConfigPath = %q, want %q", path, expected) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index b160f391..b2740b66 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -254,3 +254,57 @@ func DefaultAccountsConfigDir() string { home, _ := os.UserHomeDir() return home + "/.claude-accounts" } + +// MessagingConfig represents the messaging configuration (config/messaging.json). +// This defines mailing lists, work queues, and announcement channels. +type MessagingConfig struct { + Version int `json:"version"` // schema version + + // Lists are static mailing lists. Messages are fanned out to all recipients. + // Each recipient gets their own copy of the message. + // Example: {"oncall": ["mayor/", "gastown/witness"]} + Lists map[string][]string `json:"lists,omitempty"` + + // Queues are shared work queues. Only one copy exists; workers claim messages. + // Messages sit in the queue until explicitly claimed by a worker. + // Example: {"work/gastown": ["gastown/polecats/*"]} + Queues map[string]QueueConfig `json:"queues,omitempty"` + + // Announces are bulletin boards. One copy exists; anyone can read, no claiming. + // Used for broadcast announcements that don't need acknowledgment. + // Example: {"alerts": {"readers": ["@town"]}} + Announces map[string]AnnounceConfig `json:"announces,omitempty"` +} + +// QueueConfig represents a work queue configuration. +type QueueConfig struct { + // Workers lists addresses eligible to claim from this queue. + // Supports wildcards: "gastown/polecats/*" matches all polecats in gastown. + Workers []string `json:"workers"` + + // MaxClaims is the maximum number of concurrent claims (0 = unlimited). + MaxClaims int `json:"max_claims,omitempty"` +} + +// AnnounceConfig represents a bulletin board configuration. +type AnnounceConfig struct { + // Readers lists addresses eligible to read from this announce channel. + // Supports @group syntax: "@town", "@rig/gastown", "@witnesses". + Readers []string `json:"readers"` + + // RetainCount is the number of messages to retain (0 = unlimited). + RetainCount int `json:"retain_count,omitempty"` +} + +// CurrentMessagingVersion is the current schema version for MessagingConfig. +const CurrentMessagingVersion = 1 + +// NewMessagingConfig creates a new MessagingConfig with defaults. +func NewMessagingConfig() *MessagingConfig { + return &MessagingConfig{ + Version: CurrentMessagingVersion, + Lists: make(map[string][]string), + Queues: make(map[string]QueueConfig), + Announces: make(map[string]AnnounceConfig), + } +}