Implement three-tier config architecture (gt-k1lr tasks 1-5)
**Architecture changes:** - Renamed `.gastown/` → `.runtime/` for runtime state (gitignored) - Added `settings/` directory for rig behavioral config (git-tracked) - Added `mayor/config.json` for town-level config (MayorConfig type) - Separated RigConfig (identity) from RigSettings (behavioral) **File location changes:** - Town runtime: `~/.gastown/*` → `~/.runtime/*` - Rig runtime: `<rig>/.gastown/*` → `<rig>/.runtime/*` - Rig config: `<rig>/.gastown/config.json` → `<rig>/settings/config.json` - Namepool state: `namepool.json` → `namepool-state.json` **New types:** - MayorConfig: town-level behavioral config - RigSettings: rig behavioral config (merge_queue, theme, namepool) - RigConfig now identity-only (name, git_url, beads, created_at) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+137
-8
@@ -232,7 +232,7 @@ func SaveRigConfig(path string, config *RigConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRigConfig validates a RigConfig.
|
||||
// validateRigConfig validates a RigConfig (identity only).
|
||||
func validateRigConfig(c *RigConfig) error {
|
||||
if c.Type != "rig" && c.Type != "" {
|
||||
return fmt.Errorf("%w: expected type 'rig', got '%s'", ErrInvalidType, c.Type)
|
||||
@@ -240,14 +240,25 @@ func validateRigConfig(c *RigConfig) error {
|
||||
if c.Version > CurrentRigConfigVersion {
|
||||
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentRigConfigVersion)
|
||||
}
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("%w: name", ErrMissingField)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate merge queue config if present
|
||||
// validateRigSettings validates a RigSettings.
|
||||
func validateRigSettings(c *RigSettings) error {
|
||||
if c.Type != "rig-settings" && c.Type != "" {
|
||||
return fmt.Errorf("%w: expected type 'rig-settings', got '%s'", ErrInvalidType, c.Type)
|
||||
}
|
||||
if c.Version > CurrentRigSettingsVersion {
|
||||
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentRigSettingsVersion)
|
||||
}
|
||||
if c.MergeQueue != nil {
|
||||
if err := validateMergeQueueConfig(c.MergeQueue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -280,11 +291,129 @@ func validateMergeQueueConfig(c *MergeQueueConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRigConfig creates a new RigConfig with defaults.
|
||||
func NewRigConfig() *RigConfig {
|
||||
// NewRigConfig creates a new RigConfig (identity only).
|
||||
func NewRigConfig(name, gitURL string) *RigConfig {
|
||||
return &RigConfig{
|
||||
Type: "rig",
|
||||
Version: CurrentRigConfigVersion,
|
||||
MergeQueue: DefaultMergeQueueConfig(),
|
||||
Type: "rig",
|
||||
Version: CurrentRigConfigVersion,
|
||||
Name: name,
|
||||
GitURL: gitURL,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRigSettings creates a new RigSettings with defaults.
|
||||
func NewRigSettings() *RigSettings {
|
||||
return &RigSettings{
|
||||
Type: "rig-settings",
|
||||
Version: CurrentRigSettingsVersion,
|
||||
MergeQueue: DefaultMergeQueueConfig(),
|
||||
Namepool: DefaultNamepoolConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadRigSettings loads and validates a rig settings file.
|
||||
func LoadRigSettings(path string) (*RigSettings, 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 settings: %w", err)
|
||||
}
|
||||
|
||||
var settings RigSettings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, fmt.Errorf("parsing settings: %w", err)
|
||||
}
|
||||
|
||||
if err := validateRigSettings(&settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SaveRigSettings saves rig settings to a file.
|
||||
func SaveRigSettings(path string, settings *RigSettings) error {
|
||||
if err := validateRigSettings(settings); 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(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding settings: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing settings: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadMayorConfig loads and validates a mayor config file.
|
||||
func LoadMayorConfig(path string) (*MayorConfig, 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 config: %w", err)
|
||||
}
|
||||
|
||||
var config MayorConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
if err := validateMayorConfig(&config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SaveMayorConfig saves a mayor config to a file.
|
||||
func SaveMayorConfig(path string, config *MayorConfig) error {
|
||||
if err := validateMayorConfig(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 config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMayorConfig validates a MayorConfig.
|
||||
func validateMayorConfig(c *MayorConfig) error {
|
||||
if c.Type != "mayor-config" && c.Type != "" {
|
||||
return fmt.Errorf("%w: expected type 'mayor-config', got '%s'", ErrInvalidType, c.Type)
|
||||
}
|
||||
if c.Version > CurrentMayorConfigVersion {
|
||||
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentMayorConfigVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewMayorConfig creates a new MayorConfig with defaults.
|
||||
func NewMayorConfig() *MayorConfig {
|
||||
return &MayorConfig{
|
||||
Type: "mayor-config",
|
||||
Version: CurrentMayorConfigVersion,
|
||||
}
|
||||
}
|
||||
|
||||
+140
-29
@@ -135,7 +135,9 @@ func TestRigConfigRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
|
||||
original := NewRigConfig()
|
||||
original := NewRigConfig("gastown", "git@github.com:test/gastown.git")
|
||||
original.CreatedAt = time.Now().Truncate(time.Second)
|
||||
original.Beads = &BeadsConfig{Prefix: "gt-"}
|
||||
|
||||
if err := SaveRigConfig(path, original); err != nil {
|
||||
t.Fatalf("SaveRigConfig: %v", err)
|
||||
@@ -152,6 +154,35 @@ func TestRigConfigRoundTrip(t *testing.T) {
|
||||
if loaded.Version != CurrentRigConfigVersion {
|
||||
t.Errorf("Version = %d, want %d", loaded.Version, CurrentRigConfigVersion)
|
||||
}
|
||||
if loaded.Name != "gastown" {
|
||||
t.Errorf("Name = %q, want 'gastown'", loaded.Name)
|
||||
}
|
||||
if loaded.GitURL != "git@github.com:test/gastown.git" {
|
||||
t.Errorf("GitURL = %q, want expected URL", loaded.GitURL)
|
||||
}
|
||||
if loaded.Beads == nil || loaded.Beads.Prefix != "gt-" {
|
||||
t.Error("Beads.Prefix not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRigSettingsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "settings", "config.json")
|
||||
|
||||
original := NewRigSettings()
|
||||
|
||||
if err := SaveRigSettings(path, original); err != nil {
|
||||
t.Fatalf("SaveRigSettings: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadRigSettings(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRigSettings: %v", err)
|
||||
}
|
||||
|
||||
if loaded.Type != "rig-settings" {
|
||||
t.Errorf("Type = %q, want 'rig-settings'", loaded.Type)
|
||||
}
|
||||
if loaded.MergeQueue == nil {
|
||||
t.Fatal("MergeQueue is nil")
|
||||
}
|
||||
@@ -161,17 +192,14 @@ func TestRigConfigRoundTrip(t *testing.T) {
|
||||
if loaded.MergeQueue.TargetBranch != "main" {
|
||||
t.Errorf("MergeQueue.TargetBranch = %q, want 'main'", loaded.MergeQueue.TargetBranch)
|
||||
}
|
||||
if loaded.MergeQueue.OnConflict != OnConflictAssignBack {
|
||||
t.Errorf("MergeQueue.OnConflict = %q, want %q", loaded.MergeQueue.OnConflict, OnConflictAssignBack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRigConfigWithCustomMergeQueue(t *testing.T) {
|
||||
func TestRigSettingsWithCustomMergeQueue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
|
||||
original := &RigConfig{
|
||||
Type: "rig",
|
||||
original := &RigSettings{
|
||||
Type: "rig-settings",
|
||||
Version: 1,
|
||||
MergeQueue: &MergeQueueConfig{
|
||||
Enabled: true,
|
||||
@@ -187,13 +215,13 @@ func TestRigConfigWithCustomMergeQueue(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := SaveRigConfig(path, original); err != nil {
|
||||
t.Fatalf("SaveRigConfig: %v", err)
|
||||
if err := SaveRigSettings(path, original); err != nil {
|
||||
t.Fatalf("SaveRigSettings: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadRigConfig(path)
|
||||
loaded, err := LoadRigSettings(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRigConfig: %v", err)
|
||||
t.Fatalf("LoadRigSettings: %v", err)
|
||||
}
|
||||
|
||||
mq := loaded.MergeQueue
|
||||
@@ -209,12 +237,6 @@ func TestRigConfigWithCustomMergeQueue(t *testing.T) {
|
||||
if mq.RetryFlakyTests != 3 {
|
||||
t.Errorf("RetryFlakyTests = %d, want 3", mq.RetryFlakyTests)
|
||||
}
|
||||
if mq.PollInterval != "1m" {
|
||||
t.Errorf("PollInterval = %q, want '1m'", mq.PollInterval)
|
||||
}
|
||||
if mq.MaxConcurrent != 2 {
|
||||
t.Errorf("MaxConcurrent = %d, want 2", mq.MaxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRigConfigValidation(t *testing.T) {
|
||||
@@ -226,23 +248,67 @@ func TestRigConfigValidation(t *testing.T) {
|
||||
{
|
||||
name: "valid config",
|
||||
config: &RigConfig{
|
||||
Type: "rig",
|
||||
Type: "rig",
|
||||
Version: 1,
|
||||
Name: "test-rig",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
config: &RigConfig{
|
||||
Type: "rig",
|
||||
Version: 1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong type",
|
||||
config: &RigConfig{
|
||||
Type: "wrong",
|
||||
Version: 1,
|
||||
Name: "test",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRigConfig(tt.config)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateRigConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRigSettingsValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings *RigSettings
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid settings",
|
||||
settings: &RigSettings{
|
||||
Type: "rig-settings",
|
||||
Version: 1,
|
||||
MergeQueue: DefaultMergeQueueConfig(),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config without merge queue",
|
||||
config: &RigConfig{
|
||||
Type: "rig",
|
||||
name: "valid settings without merge queue",
|
||||
settings: &RigSettings{
|
||||
Type: "rig-settings",
|
||||
Version: 1,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "wrong type",
|
||||
config: &RigConfig{
|
||||
settings: &RigSettings{
|
||||
Type: "wrong",
|
||||
Version: 1,
|
||||
},
|
||||
@@ -250,8 +316,8 @@ func TestRigConfigValidation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid on_conflict",
|
||||
config: &RigConfig{
|
||||
Type: "rig",
|
||||
settings: &RigSettings{
|
||||
Type: "rig-settings",
|
||||
Version: 1,
|
||||
MergeQueue: &MergeQueueConfig{
|
||||
OnConflict: "invalid",
|
||||
@@ -261,8 +327,8 @@ func TestRigConfigValidation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid poll_interval",
|
||||
config: &RigConfig{
|
||||
Type: "rig",
|
||||
settings: &RigSettings{
|
||||
Type: "rig-settings",
|
||||
Version: 1,
|
||||
MergeQueue: &MergeQueueConfig{
|
||||
PollInterval: "not-a-duration",
|
||||
@@ -274,9 +340,9 @@ func TestRigConfigValidation(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRigConfig(tt.config)
|
||||
err := validateRigSettings(tt.settings)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateRigConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
t.Errorf("validateRigSettings() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -323,3 +389,48 @@ func TestLoadRigConfigNotFound(t *testing.T) {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRigSettingsNotFound(t *testing.T) {
|
||||
_, err := LoadRigSettings("/nonexistent/path.json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMayorConfigRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mayor", "config.json")
|
||||
|
||||
original := NewMayorConfig()
|
||||
original.Theme = &TownThemeConfig{
|
||||
RoleDefaults: map[string]string{
|
||||
"witness": "rust",
|
||||
},
|
||||
}
|
||||
|
||||
if err := SaveMayorConfig(path, original); err != nil {
|
||||
t.Fatalf("SaveMayorConfig: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadMayorConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMayorConfig: %v", err)
|
||||
}
|
||||
|
||||
if loaded.Type != "mayor-config" {
|
||||
t.Errorf("Type = %q, want 'mayor-config'", loaded.Type)
|
||||
}
|
||||
if loaded.Version != CurrentMayorConfigVersion {
|
||||
t.Errorf("Version = %d, want %d", loaded.Version, CurrentMayorConfigVersion)
|
||||
}
|
||||
if loaded.Theme == nil || loaded.Theme.RoleDefaults["witness"] != "rust" {
|
||||
t.Error("Theme.RoleDefaults not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMayorConfigNotFound(t *testing.T) {
|
||||
_, err := LoadMayorConfig("/nonexistent/path.json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,38 @@ package config
|
||||
|
||||
import "time"
|
||||
|
||||
// TownConfig represents the main town configuration (mayor/town.json).
|
||||
// TownConfig represents the main town identity (mayor/town.json).
|
||||
type TownConfig struct {
|
||||
Type string `json:"type"` // "town"
|
||||
Version int `json:"version"` // schema version
|
||||
Name string `json:"name"` // town identifier
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Theme *TownThemeConfig `json:"theme,omitempty"` // global theme settings
|
||||
Type string `json:"type"` // "town"
|
||||
Version int `json:"version"` // schema version
|
||||
Name string `json:"name"` // town identifier
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// MayorConfig represents town-level behavioral configuration (mayor/config.json).
|
||||
// This is separate from TownConfig (identity) to keep configuration concerns distinct.
|
||||
type MayorConfig struct {
|
||||
Type string `json:"type"` // "mayor-config"
|
||||
Version int `json:"version"` // schema version
|
||||
Theme *TownThemeConfig `json:"theme,omitempty"` // global theme settings
|
||||
Daemon *DaemonConfig `json:"daemon,omitempty"` // daemon settings
|
||||
Deacon *DeaconConfig `json:"deacon,omitempty"` // deacon settings
|
||||
}
|
||||
|
||||
// DaemonConfig represents daemon process settings.
|
||||
type DaemonConfig struct {
|
||||
HeartbeatInterval string `json:"heartbeat_interval,omitempty"` // e.g., "30s"
|
||||
PollInterval string `json:"poll_interval,omitempty"` // e.g., "10s"
|
||||
}
|
||||
|
||||
// DeaconConfig represents deacon process settings.
|
||||
type DeaconConfig struct {
|
||||
PatrolInterval string `json:"patrol_interval,omitempty"` // e.g., "5m"
|
||||
}
|
||||
|
||||
// CurrentMayorConfigVersion is the current schema version for MayorConfig.
|
||||
const CurrentMayorConfigVersion = 1
|
||||
|
||||
// RigsConfig represents the rigs registry (mayor/rigs.json).
|
||||
type RigsConfig struct {
|
||||
Version int `json:"version"`
|
||||
@@ -48,9 +71,23 @@ const CurrentRigsVersion = 1
|
||||
// CurrentRigConfigVersion is the current schema version for RigConfig.
|
||||
const CurrentRigConfigVersion = 1
|
||||
|
||||
// RigConfig represents the per-rig configuration (rig/config.json).
|
||||
// CurrentRigSettingsVersion is the current schema version for RigSettings.
|
||||
const CurrentRigSettingsVersion = 1
|
||||
|
||||
// RigConfig represents per-rig identity (rig/config.json).
|
||||
// This contains only identity - behavioral config is in settings/config.json.
|
||||
type RigConfig struct {
|
||||
Type string `json:"type"` // "rig"
|
||||
Type string `json:"type"` // "rig"
|
||||
Version int `json:"version"` // schema version
|
||||
Name string `json:"name"` // rig name
|
||||
GitURL string `json:"git_url"` // git repository URL
|
||||
CreatedAt time.Time `json:"created_at"` // when the rig was created
|
||||
Beads *BeadsConfig `json:"beads,omitempty"`
|
||||
}
|
||||
|
||||
// RigSettings represents per-rig behavioral configuration (settings/config.json).
|
||||
type RigSettings struct {
|
||||
Type string `json:"type"` // "rig-settings"
|
||||
Version int `json:"version"` // schema version
|
||||
MergeQueue *MergeQueueConfig `json:"merge_queue,omitempty"` // merge queue settings
|
||||
Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings
|
||||
|
||||
Reference in New Issue
Block a user