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:
Subhrajit Makur
2026-01-07 02:29:41 +05:30
committed by GitHub
parent 9d7dcde1e2
commit 7fe505d673
6 changed files with 555 additions and 44 deletions
+27 -38
View File
@@ -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
+180
View File
@@ -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")
}
}