From d94fb4669b85b2b9903d291b7cdb2779c328a4c7 Mon Sep 17 00:00:00 2001 From: gastown/polecats/toast Date: Tue, 30 Dec 2025 22:47:14 -0800 Subject: [PATCH] Add nudge_channels to MessagingConfig schema (gt-3shmx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NudgeChannels field to MessagingConfig struct in types.go - Initialize NudgeChannels map in NewMessagingConfig() - Add validation in validateMessagingConfig(): channel names must be non-empty and each channel must have at least one recipient - Add tests for valid nudge channels and empty recipient validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/config/loader.go | 13 +++++++++++++ internal/config/loader_test.go | 34 ++++++++++++++++++++++++++++++++++ internal/config/types.go | 16 +++++++++++----- 3 files changed, 58 insertions(+), 5 deletions(-) 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), } }