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,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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user