Add messaging config types and load/save functions (gt-i6jvc)

Add MessagingConfig with:
- Lists: static mailing lists with fan-out delivery
- Queues: shared work queues with claiming
- Announces: bulletin boards for broadcast messages

Includes:
- types.go: MessagingConfig, QueueConfig, AnnounceConfig types
- loader.go: Load/Save/Validate functions, MessagingConfigPath helper
- loader_test.go: Round-trip and validation tests

Created ~/gt/config/messaging.json with example configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-28 14:07:30 -08:00
parent 176ad3ae69
commit 6fb4851301
3 changed files with 345 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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),
}
}