diff --git a/internal/config/loader.go b/internal/config/loader.go index 57d1138f..e3e43d02 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -629,6 +629,9 @@ func validateMessagingConfig(c *MessagingConfig) error { if c.Announces == nil { c.Announces = make(map[string]AnnounceConfig) } + if c.NudgeChannels == nil { + c.NudgeChannels = make(map[string][]string) + } // Validate lists have at least one recipient for name, recipients := range c.Lists { @@ -657,6 +660,16 @@ func validateMessagingConfig(c *MessagingConfig) error { } } + // Validate nudge channels have non-empty names and at least one recipient + for name, recipients := range c.NudgeChannels { + if name == "" { + return fmt.Errorf("%w: nudge channel name cannot be empty", ErrMissingField) + } + if len(recipients) == 0 { + return fmt.Errorf("%w: nudge channel '%s' has no recipients", ErrMissingField, name) + } + } + return nil } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 79157b5e..d74625c2 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -566,6 +566,8 @@ func TestMessagingConfigRoundTrip(t *testing.T) { Readers: []string{"@town"}, RetainCount: 100, } + original.NudgeChannels["workers"] = []string{"gastown/polecats/*", "gastown/crew/*"} + original.NudgeChannels["witnesses"] = []string{"*/witness"} if err := SaveMessagingConfig(path, original); err != nil { t.Fatalf("SaveMessagingConfig: %v", err) @@ -606,6 +608,17 @@ func TestMessagingConfigRoundTrip(t *testing.T) { if a, ok := loaded.Announces["alerts"]; !ok || a.RetainCount != 100 { t.Error("announce not preserved") } + + // Check nudge channels + if len(loaded.NudgeChannels) != 2 { + t.Errorf("NudgeChannels count = %d, want 2", len(loaded.NudgeChannels)) + } + if workers, ok := loaded.NudgeChannels["workers"]; !ok || len(workers) != 2 { + t.Error("workers nudge channel not preserved") + } + if witnesses, ok := loaded.NudgeChannels["witnesses"]; !ok || len(witnesses) != 1 { + t.Error("witnesses nudge channel not preserved") + } } func TestMessagingConfigValidation(t *testing.T) { @@ -696,6 +709,27 @@ func TestMessagingConfigValidation(t *testing.T) { }, wantErr: true, }, + { + name: "valid config with nudge channels", + config: &MessagingConfig{ + Type: "messaging", + Version: 1, + NudgeChannels: map[string][]string{ + "workers": {"gastown/polecats/*", "gastown/crew/*"}, + }, + }, + wantErr: false, + }, + { + name: "nudge channel with no recipients", + config: &MessagingConfig{ + Version: 1, + NudgeChannels: map[string][]string{ + "empty": {}, + }, + }, + wantErr: true, + }, } for _, tt := range tests { diff --git a/internal/config/types.go b/internal/config/types.go index fe63f2c9..66c05df2 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -278,6 +278,11 @@ type MessagingConfig struct { // Used for broadcast announcements that don't need acknowledgment. // Example: {"alerts": {"readers": ["@town"]}} Announces map[string]AnnounceConfig `json:"announces,omitempty"` + + // NudgeChannels are named groups for real-time nudge fan-out. + // Like mailing lists but for tmux send-keys instead of durable mail. + // Example: {"workers": ["gastown/polecats/*", "gastown/crew/*"], "witnesses": ["*/witness"]} + NudgeChannels map[string][]string `json:"nudge_channels,omitempty"` } // QueueConfig represents a work queue configuration. @@ -306,10 +311,11 @@ const CurrentMessagingVersion = 1 // NewMessagingConfig creates a new MessagingConfig with defaults. func NewMessagingConfig() *MessagingConfig { return &MessagingConfig{ - Type: "messaging", - Version: CurrentMessagingVersion, - Lists: make(map[string][]string), - Queues: make(map[string]QueueConfig), - Announces: make(map[string]AnnounceConfig), + Type: "messaging", + Version: CurrentMessagingVersion, + Lists: make(map[string][]string), + Queues: make(map[string]QueueConfig), + Announces: make(map[string]AnnounceConfig), + NudgeChannels: make(map[string][]string), } }