- Add internal/lifecycle/manager.go with Manager struct, Run() ticker loop (15s interval), EnsureAllSessions() for boot-time session creation, and reconcile() that recreates idle sessions and recovers working ones via SetFailed + CreateSession - Add state.SetFailed() to record crash timestamp on SessionState - Add internal/lifecycle/manager_test.go with mock tmux client and 3 tests: TestReconcileCreatesDeadSession, TestReconcileRecoversCrashedSession, TestEnsureAllSessions — all pass - Wire lifecycle.Manager into cmd/claude-failover/main.go after state init Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
161 lines
4.3 KiB
Go
161 lines
4.3 KiB
Go
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|