diff --git a/internal/cmd/start.go b/internal/cmd/start.go index b0b0436a..b8e83478 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -145,6 +145,10 @@ func runStart(cmd *cobra.Command, args []string) error { return fmt.Errorf("not in a Gas Town workspace: %w", err) } + if err := config.EnsureDaemonPatrolConfig(townRoot); err != nil { + fmt.Printf(" %s Could not ensure daemon config: %v\n", style.Dim.Render("○"), err) + } + t := tmux.NewTmux() fmt.Printf("Starting Gas Town from %s\n\n", style.Dim.Render(townRoot)) diff --git a/internal/config/loader.go b/internal/config/loader.go index 986f7a0c..5b38f4cb 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -9,6 +9,8 @@ import ( "sort" "strings" "time" + + "github.com/steveyegge/gastown/internal/constants" ) var ( @@ -368,6 +370,77 @@ func NewMayorConfig() *MayorConfig { } } +// DaemonPatrolConfigPath returns the path to the daemon patrol config file. +func DaemonPatrolConfigPath(townRoot string) string { + return filepath.Join(townRoot, constants.DirMayor, DaemonPatrolConfigFileName) +} + +// LoadDaemonPatrolConfig loads and validates a daemon patrol config file. +func LoadDaemonPatrolConfig(path string) (*DaemonPatrolConfig, error) { + data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotFound, path) + } + return nil, fmt.Errorf("reading daemon patrol config: %w", err) + } + + var config DaemonPatrolConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("parsing daemon patrol config: %w", err) + } + + if err := validateDaemonPatrolConfig(&config); err != nil { + return nil, err + } + + return &config, nil +} + +// SaveDaemonPatrolConfig saves a daemon patrol config to a file. +func SaveDaemonPatrolConfig(path string, config *DaemonPatrolConfig) error { + if err := validateDaemonPatrolConfig(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 daemon patrol config: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: config files don't contain secrets + return fmt.Errorf("writing daemon patrol config: %w", err) + } + + return nil +} + +func validateDaemonPatrolConfig(c *DaemonPatrolConfig) error { + if c.Type != "daemon-patrol-config" && c.Type != "" { + return fmt.Errorf("%w: expected type 'daemon-patrol-config', got '%s'", ErrInvalidType, c.Type) + } + if c.Version > CurrentDaemonPatrolConfigVersion { + return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentDaemonPatrolConfigVersion) + } + return nil +} + +// EnsureDaemonPatrolConfig creates the daemon patrol config if it doesn't exist. +func EnsureDaemonPatrolConfig(townRoot string) error { + path := DaemonPatrolConfigPath(townRoot) + if _, err := os.Stat(path); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("checking daemon patrol config: %w", err) + } + return SaveDaemonPatrolConfig(path, NewDaemonPatrolConfig()) + } + return nil +} + // LoadAccountsConfig loads and validates an accounts configuration file. func LoadAccountsConfig(path string) (*AccountsConfig, error) { data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 2dcb4510..43ebba23 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -971,6 +971,214 @@ func TestLoadRuntimeConfigFallsBackToDefaults(t *testing.T) { } } +func TestDaemonPatrolConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mayor", "daemon.json") + + original := NewDaemonPatrolConfig() + original.Patrols["custom"] = PatrolConfig{ + Enabled: true, + Interval: "10m", + Agent: "custom-agent", + } + + if err := SaveDaemonPatrolConfig(path, original); err != nil { + t.Fatalf("SaveDaemonPatrolConfig: %v", err) + } + + loaded, err := LoadDaemonPatrolConfig(path) + if err != nil { + t.Fatalf("LoadDaemonPatrolConfig: %v", err) + } + + if loaded.Type != "daemon-patrol-config" { + t.Errorf("Type = %q, want 'daemon-patrol-config'", loaded.Type) + } + if loaded.Version != CurrentDaemonPatrolConfigVersion { + t.Errorf("Version = %d, want %d", loaded.Version, CurrentDaemonPatrolConfigVersion) + } + if loaded.Heartbeat == nil || !loaded.Heartbeat.Enabled { + t.Error("Heartbeat not preserved") + } + if len(loaded.Patrols) != 4 { + t.Errorf("Patrols count = %d, want 4", len(loaded.Patrols)) + } + if custom, ok := loaded.Patrols["custom"]; !ok || custom.Agent != "custom-agent" { + t.Error("custom patrol not preserved") + } +} + +func TestDaemonPatrolConfigValidation(t *testing.T) { + tests := []struct { + name string + config *DaemonPatrolConfig + wantErr bool + }{ + { + name: "valid default config", + config: NewDaemonPatrolConfig(), + wantErr: false, + }, + { + name: "valid minimal config", + config: &DaemonPatrolConfig{ + Type: "daemon-patrol-config", + Version: 1, + }, + wantErr: false, + }, + { + name: "wrong type", + config: &DaemonPatrolConfig{ + Type: "wrong", + Version: 1, + }, + wantErr: true, + }, + { + name: "future version rejected", + config: &DaemonPatrolConfig{ + Type: "daemon-patrol-config", + Version: 999, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDaemonPatrolConfig(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("validateDaemonPatrolConfig() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestLoadDaemonPatrolConfigNotFound(t *testing.T) { + _, err := LoadDaemonPatrolConfig("/nonexistent/path.json") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestDaemonPatrolConfigPath(t *testing.T) { + tests := []struct { + townRoot string + expected string + }{ + {"/home/user/gt", "/home/user/gt/mayor/daemon.json"}, + {"/var/lib/gastown", "/var/lib/gastown/mayor/daemon.json"}, + {"/tmp/test-workspace", "/tmp/test-workspace/mayor/daemon.json"}, + {"~/gt", "~/gt/mayor/daemon.json"}, + } + + for _, tt := range tests { + t.Run(tt.townRoot, func(t *testing.T) { + path := DaemonPatrolConfigPath(tt.townRoot) + if path != tt.expected { + t.Errorf("DaemonPatrolConfigPath(%q) = %q, want %q", tt.townRoot, path, tt.expected) + } + }) + } +} + +func TestEnsureDaemonPatrolConfig(t *testing.T) { + t.Run("creates config if missing", func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "mayor"), 0755); err != nil { + t.Fatalf("creating mayor dir: %v", err) + } + + err := EnsureDaemonPatrolConfig(dir) + if err != nil { + t.Fatalf("EnsureDaemonPatrolConfig: %v", err) + } + + path := DaemonPatrolConfigPath(dir) + loaded, err := LoadDaemonPatrolConfig(path) + if err != nil { + t.Fatalf("LoadDaemonPatrolConfig: %v", err) + } + if loaded.Type != "daemon-patrol-config" { + t.Errorf("Type = %q, want 'daemon-patrol-config'", loaded.Type) + } + if len(loaded.Patrols) != 3 { + t.Errorf("Patrols count = %d, want 3 (deacon, witness, refinery)", len(loaded.Patrols)) + } + }) + + t.Run("preserves existing config", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mayor", "daemon.json") + + existing := &DaemonPatrolConfig{ + Type: "daemon-patrol-config", + Version: 1, + Patrols: map[string]PatrolConfig{ + "custom-only": {Enabled: true, Agent: "custom"}, + }, + } + if err := SaveDaemonPatrolConfig(path, existing); err != nil { + t.Fatalf("SaveDaemonPatrolConfig: %v", err) + } + + err := EnsureDaemonPatrolConfig(dir) + if err != nil { + t.Fatalf("EnsureDaemonPatrolConfig: %v", err) + } + + loaded, err := LoadDaemonPatrolConfig(path) + if err != nil { + t.Fatalf("LoadDaemonPatrolConfig: %v", err) + } + if len(loaded.Patrols) != 1 { + t.Errorf("Patrols count = %d, want 1 (should preserve existing)", len(loaded.Patrols)) + } + if _, ok := loaded.Patrols["custom-only"]; !ok { + t.Error("existing custom patrol was overwritten") + } + }) + +} + +func TestNewDaemonPatrolConfig(t *testing.T) { + cfg := NewDaemonPatrolConfig() + + if cfg.Type != "daemon-patrol-config" { + t.Errorf("Type = %q, want 'daemon-patrol-config'", cfg.Type) + } + if cfg.Version != CurrentDaemonPatrolConfigVersion { + t.Errorf("Version = %d, want %d", cfg.Version, CurrentDaemonPatrolConfigVersion) + } + if cfg.Heartbeat == nil { + t.Fatal("Heartbeat is nil") + } + if !cfg.Heartbeat.Enabled { + t.Error("Heartbeat.Enabled should be true by default") + } + if cfg.Heartbeat.Interval != "3m" { + t.Errorf("Heartbeat.Interval = %q, want '3m'", cfg.Heartbeat.Interval) + } + if len(cfg.Patrols) != 3 { + t.Errorf("Patrols count = %d, want 3", len(cfg.Patrols)) + } + + for _, name := range []string{"deacon", "witness", "refinery"} { + patrol, ok := cfg.Patrols[name] + if !ok { + t.Errorf("missing %s patrol", name) + continue + } + if !patrol.Enabled { + t.Errorf("%s patrol should be enabled by default", name) + } + if patrol.Agent != name { + t.Errorf("%s patrol Agent = %q, want %q", name, patrol.Agent, name) + } + } +} + func TestSaveTownSettings(t *testing.T) { t.Run("saves valid town settings", func(t *testing.T) { tmpDir := t.TempDir() diff --git a/internal/config/types.go b/internal/config/types.go index 27e46e8f..ee6dabfa 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -66,6 +66,63 @@ type DaemonConfig struct { PollInterval string `json:"poll_interval,omitempty"` // e.g., "10s" } +// DaemonPatrolConfig represents the daemon patrol configuration (mayor/daemon.json). +// This configures how patrols are triggered and managed. +type DaemonPatrolConfig struct { + Type string `json:"type"` // "daemon-patrol-config" + Version int `json:"version"` // schema version + Heartbeat *HeartbeatConfig `json:"heartbeat,omitempty"` // heartbeat settings + Patrols map[string]PatrolConfig `json:"patrols,omitempty"` // named patrol configurations +} + +// HeartbeatConfig represents heartbeat settings for daemon. +type HeartbeatConfig struct { + Enabled bool `json:"enabled"` // whether heartbeat is enabled + Interval string `json:"interval,omitempty"` // e.g., "3m" +} + +// PatrolConfig represents a single patrol configuration. +type PatrolConfig struct { + Enabled bool `json:"enabled"` // whether this patrol is enabled + Interval string `json:"interval,omitempty"` // e.g., "5m" + Agent string `json:"agent,omitempty"` // agent that runs this patrol +} + +// CurrentDaemonPatrolConfigVersion is the current schema version for DaemonPatrolConfig. +const CurrentDaemonPatrolConfigVersion = 1 + +// DaemonPatrolConfigFileName is the filename for daemon patrol configuration. +const DaemonPatrolConfigFileName = "daemon.json" + +// NewDaemonPatrolConfig creates a new DaemonPatrolConfig with sensible defaults. +func NewDaemonPatrolConfig() *DaemonPatrolConfig { + return &DaemonPatrolConfig{ + Type: "daemon-patrol-config", + Version: CurrentDaemonPatrolConfigVersion, + Heartbeat: &HeartbeatConfig{ + Enabled: true, + Interval: "3m", + }, + Patrols: map[string]PatrolConfig{ + "deacon": { + Enabled: true, + Interval: "5m", + Agent: "deacon", + }, + "witness": { + Enabled: true, + Interval: "5m", + Agent: "witness", + }, + "refinery": { + Enabled: true, + Interval: "5m", + Agent: "refinery", + }, + }, + } +} + // DeaconConfig represents deacon process settings. type DeaconConfig struct { PatrolInterval string `json:"patrol_interval,omitempty"` // e.g., "5m" @@ -113,10 +170,10 @@ 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" - Version int `json:"version"` // schema version - Name string `json:"name"` // rig name - GitURL string `json:"git_url"` // git repository URL + 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 LocalRepo string `json:"local_repo,omitempty"` CreatedAt time.Time `json:"created_at"` // when the rig was created Beads *BeadsConfig `json:"beads,omitempty"` @@ -264,8 +321,8 @@ type TownThemeConfig struct { // These are used when no explicit configuration is provided. func BuiltinRoleThemes() map[string]string { return map[string]string{ - "witness": "rust", // Red/rust - watchful, alert - "refinery": "plum", // Purple - processing, refining + "witness": "rust", // Red/rust - watchful, alert + "refinery": "plum", // Purple - processing, refining // crew and polecat use rig theme by default (no override) } } diff --git a/internal/doctor/patrol_check.go b/internal/doctor/patrol_check.go index 395c7212..5efd2b8f 100644 --- a/internal/doctor/patrol_check.go +++ b/internal/doctor/patrol_check.go @@ -145,34 +145,36 @@ func getPatrolMoleculeDesc(title string) string { // PatrolHooksWiredCheck verifies that hooks trigger patrol execution. type PatrolHooksWiredCheck struct { - BaseCheck + FixableCheck } // NewPatrolHooksWiredCheck creates a new patrol hooks wired check. func NewPatrolHooksWiredCheck() *PatrolHooksWiredCheck { return &PatrolHooksWiredCheck{ - BaseCheck: BaseCheck{ - CheckName: "patrol-hooks-wired", - CheckDescription: "Check if hooks trigger patrol execution", + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "patrol-hooks-wired", + CheckDescription: "Check if hooks trigger patrol execution", + }, }, } } // Run checks if patrol hooks are wired. func (c *PatrolHooksWiredCheck) Run(ctx *CheckContext) *CheckResult { - // Check for daemon config which manages patrols - daemonConfigPath := filepath.Join(ctx.TownRoot, "mayor", "daemon.json") + daemonConfigPath := config.DaemonPatrolConfigPath(ctx.TownRoot) + relPath, _ := filepath.Rel(ctx.TownRoot, daemonConfigPath) + if _, err := os.Stat(daemonConfigPath); os.IsNotExist(err) { return &CheckResult{ Name: c.Name(), Status: StatusWarning, - Message: "Daemon config not found", - FixHint: "Run 'gt daemon start' to start the daemon", + Message: fmt.Sprintf("%s not found", relPath), + FixHint: "Run 'gt doctor --fix' to create default config, or 'gt daemon start' to start the daemon", } } - // Check daemon config for patrol configuration - data, err := os.ReadFile(daemonConfigPath) + cfg, err := config.LoadDaemonPatrolConfig(daemonConfigPath) if err != nil { return &CheckResult{ Name: c.Name(), @@ -182,48 +184,35 @@ func (c *PatrolHooksWiredCheck) Run(ctx *CheckContext) *CheckResult { } } - var config map[string]interface{} - if err := json.Unmarshal(data, &config); err != nil { + if len(cfg.Patrols) > 0 { return &CheckResult{ Name: c.Name(), - Status: StatusWarning, - Message: "Invalid daemon config format", - Details: []string{err.Error()}, + Status: StatusOK, + Message: fmt.Sprintf("Daemon configured with %d patrol(s)", len(cfg.Patrols)), } } - // Check for patrol entries - if patrols, ok := config["patrols"]; ok { - if patrolMap, ok := patrols.(map[string]interface{}); ok && len(patrolMap) > 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: fmt.Sprintf("Daemon configured with %d patrol(s)", len(patrolMap)), - } - } - } - - // Check if heartbeat is enabled (triggers deacon patrol) - if heartbeat, ok := config["heartbeat"]; ok { - if hb, ok := heartbeat.(map[string]interface{}); ok { - if enabled, ok := hb["enabled"].(bool); ok && enabled { - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "Daemon heartbeat enabled (triggers patrols)", - } - } + if cfg.Heartbeat != nil && cfg.Heartbeat.Enabled { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Daemon heartbeat enabled (triggers patrols)", } } return &CheckResult{ Name: c.Name(), Status: StatusWarning, - Message: "Patrol hooks not configured in daemon", - FixHint: "Configure patrols in mayor/daemon.json or run 'gt daemon start'", + Message: fmt.Sprintf("Configure patrols in %s or run 'gt daemon start'", relPath), + FixHint: "Run 'gt doctor --fix' to create default config", } } +// Fix creates the daemon patrol config with defaults. +func (c *PatrolHooksWiredCheck) Fix(ctx *CheckContext) error { + return config.EnsureDaemonPatrolConfig(ctx.TownRoot) +} + // PatrolNotStuckCheck detects wisps that have been in_progress too long. type PatrolNotStuckCheck struct { BaseCheck diff --git a/internal/doctor/patrol_check_test.go b/internal/doctor/patrol_check_test.go index 85d0cece..ee0f5de9 100644 --- a/internal/doctor/patrol_check_test.go +++ b/internal/doctor/patrol_check_test.go @@ -359,3 +359,183 @@ func TestPatrolRolesHavePromptsCheck_EmptyRigsConfig(t *testing.T) { t.Errorf("Message = %q, want 'No rigs configured'", result.Message) } } + +func TestNewPatrolHooksWiredCheck(t *testing.T) { + check := NewPatrolHooksWiredCheck() + if check == nil { + t.Fatal("NewPatrolHooksWiredCheck() returned nil") + } + if check.Name() != "patrol-hooks-wired" { + t.Errorf("Name() = %q, want %q", check.Name(), "patrol-hooks-wired") + } + if !check.CanFix() { + t.Error("CanFix() should return true") + } +} + +func TestPatrolHooksWiredCheck_NoDaemonConfig(t *testing.T) { + tmpDir := t.TempDir() + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatalf("mkdir mayor: %v", err) + } + + check := NewPatrolHooksWiredCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusWarning { + t.Errorf("Status = %v, want Warning", result.Status) + } + if result.FixHint == "" { + t.Error("FixHint should not be empty") + } +} + +func TestPatrolHooksWiredCheck_ValidConfig(t *testing.T) { + tmpDir := t.TempDir() + + cfg := config.NewDaemonPatrolConfig() + path := config.DaemonPatrolConfigPath(tmpDir) + if err := config.SaveDaemonPatrolConfig(path, cfg); err != nil { + t.Fatalf("SaveDaemonPatrolConfig: %v", err) + } + + check := NewPatrolHooksWiredCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("Status = %v, want OK", result.Status) + } +} + +func TestPatrolHooksWiredCheck_EmptyPatrols(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.DaemonPatrolConfig{ + Type: "daemon-patrol-config", + Version: 1, + Patrols: map[string]config.PatrolConfig{}, + } + path := config.DaemonPatrolConfigPath(tmpDir) + if err := config.SaveDaemonPatrolConfig(path, cfg); err != nil { + t.Fatalf("SaveDaemonPatrolConfig: %v", err) + } + + check := NewPatrolHooksWiredCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusWarning { + t.Errorf("Status = %v, want Warning (no patrols configured)", result.Status) + } +} + +func TestPatrolHooksWiredCheck_HeartbeatEnabled(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.DaemonPatrolConfig{ + Type: "daemon-patrol-config", + Version: 1, + Heartbeat: &config.HeartbeatConfig{ + Enabled: true, + Interval: "3m", + }, + Patrols: map[string]config.PatrolConfig{}, + } + path := config.DaemonPatrolConfigPath(tmpDir) + if err := config.SaveDaemonPatrolConfig(path, cfg); err != nil { + t.Fatalf("SaveDaemonPatrolConfig: %v", err) + } + + check := NewPatrolHooksWiredCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("Status = %v, want OK (heartbeat enabled triggers patrols)", result.Status) + } +} + +func TestPatrolHooksWiredCheck_Fix(t *testing.T) { + tmpDir := t.TempDir() + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatalf("mkdir mayor: %v", err) + } + + check := NewPatrolHooksWiredCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + if result.Status != StatusWarning { + t.Fatalf("Initial Status = %v, want Warning", result.Status) + } + + err := check.Fix(ctx) + if err != nil { + t.Fatalf("Fix() error = %v", err) + } + + path := config.DaemonPatrolConfigPath(tmpDir) + loaded, err := config.LoadDaemonPatrolConfig(path) + if err != nil { + t.Fatalf("LoadDaemonPatrolConfig: %v", err) + } + if loaded.Type != "daemon-patrol-config" { + t.Errorf("Type = %q, want 'daemon-patrol-config'", loaded.Type) + } + if len(loaded.Patrols) != 3 { + t.Errorf("Patrols count = %d, want 3", len(loaded.Patrols)) + } + + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("After Fix(), Status = %v, want OK", result.Status) + } +} + +func TestPatrolHooksWiredCheck_FixPreservesExisting(t *testing.T) { + tmpDir := t.TempDir() + + existing := &config.DaemonPatrolConfig{ + Type: "daemon-patrol-config", + Version: 1, + Patrols: map[string]config.PatrolConfig{ + "custom": {Enabled: true, Agent: "custom-agent"}, + }, + } + path := config.DaemonPatrolConfigPath(tmpDir) + if err := config.SaveDaemonPatrolConfig(path, existing); err != nil { + t.Fatalf("SaveDaemonPatrolConfig: %v", err) + } + + check := NewPatrolHooksWiredCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("Status = %v, want OK (has patrols)", result.Status) + } + + err := check.Fix(ctx) + if err != nil { + t.Fatalf("Fix() error = %v", err) + } + + loaded, err := config.LoadDaemonPatrolConfig(path) + if err != nil { + t.Fatalf("LoadDaemonPatrolConfig: %v", err) + } + if len(loaded.Patrols) != 1 { + t.Errorf("Patrols count = %d, want 1 (should preserve existing)", len(loaded.Patrols)) + } + if _, ok := loaded.Patrols["custom"]; !ok { + t.Error("existing custom patrol was overwritten") + } +}