claude-failover/internal/config/config.go

216 lines
6.2 KiB
Go
Raw Normal View History

// Package config loads and validates the claude-failover YAML configuration.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Config is the top-level configuration structure, mapping config.example.yaml.
type Config struct {
Accounts []AccountConfig `yaml:"accounts"`
Pool PoolConfig `yaml:"pool"`
Quota QuotaConfig `yaml:"quota"`
Checkpoint CheckpointConfig `yaml:"checkpoint"`
MCPHTTP MCPHTTPConfig `yaml:"mcp_http"`
Notifications NotificationsConfig `yaml:"notifications"`
Dispatcher DispatcherConfig `yaml:"dispatcher"`
Watcher WatcherConfig `yaml:"watcher"`
Janitor JanitorConfig `yaml:"janitor"`
}
// AccountConfig describes a single Anthropic account available to the daemon.
type AccountConfig struct {
Name string `yaml:"name"`
Home string `yaml:"home"`
Limits AccountLimits `yaml:"limits"`
Priority int `yaml:"priority"`
}
// AccountLimits defines soft usage thresholds for an account.
type AccountLimits struct {
HourlyMsgs int `yaml:"hourly_msgs"`
WeeklyMsgs int `yaml:"weekly_msgs"`
}
// PoolConfig describes the session pool configuration.
type PoolConfig struct {
Dedicated []DedicatedSession `yaml:"dedicated"`
Autonomous AutonomousConfig `yaml:"autonomous"`
SharedProjectsDir string `yaml:"shared_projects_dir"`
}
// DedicatedSession is a named tmux session bound to a specific project directory.
type DedicatedSession struct {
Name string `yaml:"name"`
Project string `yaml:"project"`
}
// AutonomousConfig controls the autoscaling inbox-dispatcher session pool.
type AutonomousConfig struct {
Prefix string `yaml:"prefix"`
Min int `yaml:"min"`
Max int `yaml:"max"`
}
// QuotaConfig defines quota monitoring parameters.
type QuotaConfig struct {
PollInterval Duration `yaml:"poll_interval"`
Window5hThreshold float64 `yaml:"window_5h_threshold"`
WindowWeekThreshold float64 `yaml:"window_week_threshold"`
ReactivateCooldown Duration `yaml:"reactivate_cooldown"`
}
// CheckpointConfig controls session context snapshotting.
type CheckpointConfig struct {
Dir string `yaml:"dir"`
Interval Duration `yaml:"interval"`
Keep int `yaml:"keep"`
}
// MCPHTTPConfig defines the HTTP control-plane endpoint.
type MCPHTTPConfig struct {
Listen string `yaml:"listen"`
BearerTokenEnv string `yaml:"bearer_token_env"`
EnableTrigger bool `yaml:"enable_trigger"`
}
// NotificationsConfig holds environment variable names for alert credentials.
// Actual secrets are read from env at runtime — never stored in config files.
type NotificationsConfig struct {
TelegramTokenEnv string `yaml:"telegram_token_env"`
TelegramChatIDEnv string `yaml:"telegram_chat_id_env"`
ResendAPIKeyEnv string `yaml:"resend_api_key_env"`
NotifyEmailEnv string `yaml:"notify_email_env"`
}
// DispatcherConfig controls the task dispatcher behaviour.
type DispatcherConfig struct {
ProjectsDir string `yaml:"projects_dir"`
IdleTimeout Duration `yaml:"idle_timeout"`
PromptTimeout Duration `yaml:"prompt_timeout"`
MaxDispatchPerTask int `yaml:"max_dispatch_per_task"`
}
// WatcherConfig controls the session watcher behaviour.
type WatcherConfig struct {
Interval Duration `yaml:"interval"`
DoneSignalDir string `yaml:"done_signal_dir"`
IdleTimeout Duration `yaml:"idle_timeout"`
}
// JanitorConfig controls the periodic housekeeping goroutine.
type JanitorConfig struct {
Interval Duration `yaml:"interval"`
}
// Duration is a time.Duration that unmarshals from YAML strings like "30s", "1h".
type Duration struct {
time.Duration
}
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
return err
}
dur, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", s, err)
}
d.Duration = dur
return nil
}
// expandHome replaces a leading "~" with the current user's home directory.
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[2:])
}
return path
}
// expandPaths resolves ~ in paths that may reference the operator's home dir.
func (c *Config) expandPaths() {
for i := range c.Accounts {
c.Accounts[i].Home = expandHome(c.Accounts[i].Home)
}
for i := range c.Pool.Dedicated {
c.Pool.Dedicated[i].Project = expandHome(c.Pool.Dedicated[i].Project)
}
c.Pool.SharedProjectsDir = expandHome(c.Pool.SharedProjectsDir)
c.Checkpoint.Dir = expandHome(c.Checkpoint.Dir)
}
// defaults sets sensible fallback values when fields are zero.
func (c *Config) defaults() {
if c.MCPHTTP.Listen == "" {
c.MCPHTTP.Listen = "127.0.0.1:9090"
}
if c.Quota.PollInterval.Duration == 0 {
c.Quota.PollInterval.Duration = 30 * time.Second
}
if c.Checkpoint.Interval.Duration == 0 {
c.Checkpoint.Interval.Duration = 60 * time.Second
}
if c.Checkpoint.Keep == 0 {
c.Checkpoint.Keep = 20
}
if c.Pool.Autonomous.Min == 0 {
c.Pool.Autonomous.Min = 2
}
if c.Pool.Autonomous.Max == 0 {
c.Pool.Autonomous.Max = 10
}
if c.Watcher.Interval.Duration == 0 {
c.Watcher.Interval.Duration = 30 * time.Second
}
if c.Watcher.IdleTimeout.Duration == 0 {
c.Watcher.IdleTimeout.Duration = 60 * time.Minute
}
if c.Watcher.DoneSignalDir == "" {
c.Watcher.DoneSignalDir = "/tmp"
}
if c.Janitor.Interval.Duration == 0 {
c.Janitor.Interval.Duration = 5 * time.Minute
}
if c.Dispatcher.IdleTimeout.Duration == 0 {
c.Dispatcher.IdleTimeout.Duration = 60 * time.Minute
}
if c.Dispatcher.PromptTimeout.Duration == 0 {
c.Dispatcher.PromptTimeout.Duration = 30 * time.Second
}
if c.Dispatcher.MaxDispatchPerTask == 0 {
c.Dispatcher.MaxDispatchPerTask = 3
}
}
// Load reads the YAML file at path, expands home paths, and applies defaults.
func Load(path string) (*Config, error) {
path = expandHome(path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config %s: %w", path, err)
}
cfg.expandPaths()
cfg.defaults()
return &cfg, nil
}