Files
gastown/internal/config/loader.go
Steve Yegge 97e0535bfe 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>
2025-12-22 01:22:43 -08:00

420 lines
11 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
var (
// ErrNotFound indicates the config file does not exist.
ErrNotFound = errors.New("config file not found")
// ErrInvalidVersion indicates an unsupported schema version.
ErrInvalidVersion = errors.New("unsupported config version")
// ErrInvalidType indicates an unexpected config type.
ErrInvalidType = errors.New("invalid config type")
// ErrMissingField indicates a required field is missing.
ErrMissingField = errors.New("missing required field")
)
// LoadTownConfig loads and validates a town configuration file.
func LoadTownConfig(path string) (*TownConfig, 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 TownConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := validateTownConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveTownConfig saves a town configuration to a file.
func SaveTownConfig(path string, config *TownConfig) error {
if err := validateTownConfig(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
}
// LoadRigsConfig loads and validates a rigs registry file.
func LoadRigsConfig(path string) (*RigsConfig, 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 RigsConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := validateRigsConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveRigsConfig saves a rigs registry to a file.
func SaveRigsConfig(path string, config *RigsConfig) error {
if err := validateRigsConfig(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
}
// LoadAgentState loads an agent state file.
func LoadAgentState(path string) (*AgentState, 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 state: %w", err)
}
var state AgentState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("parsing state: %w", err)
}
if err := validateAgentState(&state); err != nil {
return nil, err
}
return &state, nil
}
// SaveAgentState saves an agent state to a file.
func SaveAgentState(path string, state *AgentState) error {
if err := validateAgentState(state); 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(state, "", " ")
if err != nil {
return fmt.Errorf("encoding state: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing state: %w", err)
}
return nil
}
// validateTownConfig validates a TownConfig.
func validateTownConfig(c *TownConfig) error {
if c.Type != "town" && c.Type != "" {
return fmt.Errorf("%w: expected type 'town', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentTownVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentTownVersion)
}
if c.Name == "" {
return fmt.Errorf("%w: name", ErrMissingField)
}
return nil
}
// validateRigsConfig validates a RigsConfig.
func validateRigsConfig(c *RigsConfig) error {
if c.Version > CurrentRigsVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentRigsVersion)
}
if c.Rigs == nil {
c.Rigs = make(map[string]RigEntry)
}
return nil
}
// validateAgentState validates an AgentState.
func validateAgentState(s *AgentState) error {
if s.Role == "" {
return fmt.Errorf("%w: role", ErrMissingField)
}
return nil
}
// LoadRigConfig loads and validates a rig configuration file.
func LoadRigConfig(path string) (*RigConfig, 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 RigConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := validateRigConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveRigConfig saves a rig configuration to a file.
func SaveRigConfig(path string, config *RigConfig) error {
if err := validateRigConfig(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
}
// 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)
}
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
}
// 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
}
// ErrInvalidOnConflict indicates an invalid on_conflict strategy.
var ErrInvalidOnConflict = errors.New("invalid on_conflict strategy")
// validateMergeQueueConfig validates a MergeQueueConfig.
func validateMergeQueueConfig(c *MergeQueueConfig) error {
// Validate on_conflict strategy
if c.OnConflict != "" && c.OnConflict != OnConflictAssignBack && c.OnConflict != OnConflictAutoRebase {
return fmt.Errorf("%w: got '%s', want '%s' or '%s'",
ErrInvalidOnConflict, c.OnConflict, OnConflictAssignBack, OnConflictAutoRebase)
}
// Validate poll_interval if specified
if c.PollInterval != "" {
if _, err := time.ParseDuration(c.PollInterval); err != nil {
return fmt.Errorf("invalid poll_interval: %w", err)
}
}
// Validate non-negative values
if c.RetryFlakyTests < 0 {
return fmt.Errorf("%w: retry_flaky_tests must be non-negative", ErrMissingField)
}
if c.MaxConcurrent < 0 {
return fmt.Errorf("%w: max_concurrent must be non-negative", ErrMissingField)
}
return nil
}
// NewRigConfig creates a new RigConfig (identity only).
func NewRigConfig(name, gitURL string) *RigConfig {
return &RigConfig{
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,
}
}