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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user