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

View File

@@ -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

View File

@@ -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()

View File

@@ -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)
}
}