fix: create mayor/daemon.json during gt start and gt doctor --fix (#225)
* fix: create mayor/daemon.json during gt start and gt doctor --fix (#5) - Add DaemonPatrolConfig type with heartbeat and patrol settings - Add Load/Save/Ensure functions for daemon patrol config - Create daemon.json in gt start (non-fatal if fails) - Make PatrolHooksWiredCheck fixable with Fix() method - Add comprehensive tests for both config and doctor checks This fixes the issue where gt doctor expects mayor/daemon.json to exist but it was never created by gt start or any other command. * refactor: use constants.DirMayor instead of hardcoded string
This commit is contained in:
@@ -145,6 +145,10 @@ func runStart(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
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()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
fmt.Printf("Starting Gas Town from %s\n\n", style.Dim.Render(townRoot))
|
fmt.Printf("Starting Gas Town from %s\n\n", style.Dim.Render(townRoot))
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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.
|
// LoadAccountsConfig loads and validates an accounts configuration file.
|
||||||
func LoadAccountsConfig(path string) (*AccountsConfig, error) {
|
func LoadAccountsConfig(path string) (*AccountsConfig, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
|
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
|
||||||
|
|||||||
@@ -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) {
|
func TestSaveTownSettings(t *testing.T) {
|
||||||
t.Run("saves valid town settings", func(t *testing.T) {
|
t.Run("saves valid town settings", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|||||||
@@ -66,6 +66,63 @@ type DaemonConfig struct {
|
|||||||
PollInterval string `json:"poll_interval,omitempty"` // e.g., "10s"
|
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.
|
// DeaconConfig represents deacon process settings.
|
||||||
type DeaconConfig struct {
|
type DeaconConfig struct {
|
||||||
PatrolInterval string `json:"patrol_interval,omitempty"` // e.g., "5m"
|
PatrolInterval string `json:"patrol_interval,omitempty"` // e.g., "5m"
|
||||||
|
|||||||
@@ -145,34 +145,36 @@ func getPatrolMoleculeDesc(title string) string {
|
|||||||
|
|
||||||
// PatrolHooksWiredCheck verifies that hooks trigger patrol execution.
|
// PatrolHooksWiredCheck verifies that hooks trigger patrol execution.
|
||||||
type PatrolHooksWiredCheck struct {
|
type PatrolHooksWiredCheck struct {
|
||||||
BaseCheck
|
FixableCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPatrolHooksWiredCheck creates a new patrol hooks wired check.
|
// NewPatrolHooksWiredCheck creates a new patrol hooks wired check.
|
||||||
func NewPatrolHooksWiredCheck() *PatrolHooksWiredCheck {
|
func NewPatrolHooksWiredCheck() *PatrolHooksWiredCheck {
|
||||||
return &PatrolHooksWiredCheck{
|
return &PatrolHooksWiredCheck{
|
||||||
|
FixableCheck: FixableCheck{
|
||||||
BaseCheck: BaseCheck{
|
BaseCheck: BaseCheck{
|
||||||
CheckName: "patrol-hooks-wired",
|
CheckName: "patrol-hooks-wired",
|
||||||
CheckDescription: "Check if hooks trigger patrol execution",
|
CheckDescription: "Check if hooks trigger patrol execution",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run checks if patrol hooks are wired.
|
// Run checks if patrol hooks are wired.
|
||||||
func (c *PatrolHooksWiredCheck) Run(ctx *CheckContext) *CheckResult {
|
func (c *PatrolHooksWiredCheck) Run(ctx *CheckContext) *CheckResult {
|
||||||
// Check for daemon config which manages patrols
|
daemonConfigPath := config.DaemonPatrolConfigPath(ctx.TownRoot)
|
||||||
daemonConfigPath := filepath.Join(ctx.TownRoot, "mayor", "daemon.json")
|
relPath, _ := filepath.Rel(ctx.TownRoot, daemonConfigPath)
|
||||||
|
|
||||||
if _, err := os.Stat(daemonConfigPath); os.IsNotExist(err) {
|
if _, err := os.Stat(daemonConfigPath); os.IsNotExist(err) {
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusWarning,
|
Status: StatusWarning,
|
||||||
Message: "Daemon config not found",
|
Message: fmt.Sprintf("%s not found", relPath),
|
||||||
FixHint: "Run 'gt daemon start' to start the daemon",
|
FixHint: "Run 'gt doctor --fix' to create default config, or 'gt daemon start' to start the daemon",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check daemon config for patrol configuration
|
cfg, err := config.LoadDaemonPatrolConfig(daemonConfigPath)
|
||||||
data, err := os.ReadFile(daemonConfigPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
@@ -182,48 +184,35 @@ func (c *PatrolHooksWiredCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var config map[string]interface{}
|
if len(cfg.Patrols) > 0 {
|
||||||
if err := json.Unmarshal(data, &config); err != nil {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusWarning,
|
|
||||||
Message: "Invalid daemon config format",
|
|
||||||
Details: []string{err.Error()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for patrol entries
|
|
||||||
if patrols, ok := config["patrols"]; ok {
|
|
||||||
if patrolMap, ok := patrols.(map[string]interface{}); ok && len(patrolMap) > 0 {
|
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusOK,
|
Status: StatusOK,
|
||||||
Message: fmt.Sprintf("Daemon configured with %d patrol(s)", len(patrolMap)),
|
Message: fmt.Sprintf("Daemon configured with %d patrol(s)", len(cfg.Patrols)),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if heartbeat is enabled (triggers deacon patrol)
|
if cfg.Heartbeat != nil && cfg.Heartbeat.Enabled {
|
||||||
if heartbeat, ok := config["heartbeat"]; ok {
|
|
||||||
if hb, ok := heartbeat.(map[string]interface{}); ok {
|
|
||||||
if enabled, ok := hb["enabled"].(bool); ok && enabled {
|
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusOK,
|
Status: StatusOK,
|
||||||
Message: "Daemon heartbeat enabled (triggers patrols)",
|
Message: "Daemon heartbeat enabled (triggers patrols)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusWarning,
|
Status: StatusWarning,
|
||||||
Message: "Patrol hooks not configured in daemon",
|
Message: fmt.Sprintf("Configure patrols in %s or run 'gt daemon start'", relPath),
|
||||||
FixHint: "Configure patrols in mayor/daemon.json or run 'gt daemon start'",
|
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.
|
// PatrolNotStuckCheck detects wisps that have been in_progress too long.
|
||||||
type PatrolNotStuckCheck struct {
|
type PatrolNotStuckCheck struct {
|
||||||
BaseCheck
|
BaseCheck
|
||||||
|
|||||||
@@ -359,3 +359,183 @@ func TestPatrolRolesHavePromptsCheck_EmptyRigsConfig(t *testing.T) {
|
|||||||
t.Errorf("Message = %q, want 'No rigs configured'", result.Message)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user