diff --git a/internal/cmd/role.go b/internal/cmd/role.go index b788cdfc..36323bb8 100644 --- a/internal/cmd/role.go +++ b/internal/cmd/role.go @@ -100,6 +100,23 @@ Examples: RunE: runRoleEnv, } +var roleDefCmd = &cobra.Command{ + Use: "def ", + Short: "Display role definition (session, health, env config)", + Long: `Display the effective role definition after all overrides are applied. + +Role configuration is layered: + 1. Built-in defaults (embedded in binary) + 2. Town-level overrides (~/.gt/roles/.toml) + 3. Rig-level overrides (/roles/.toml) + +Examples: + gt role def witness # Show witness role definition + gt role def crew # Show crew role definition`, + Args: cobra.ExactArgs(1), + RunE: runRoleDef, +} + // Flags for role home command var ( roleRig string @@ -113,6 +130,7 @@ func init() { roleCmd.AddCommand(roleDetectCmd) roleCmd.AddCommand(roleListCmd) roleCmd.AddCommand(roleEnvCmd) + roleCmd.AddCommand(roleDefCmd) // Add --rig and --polecat flags to home command for overrides roleHomeCmd.Flags().StringVar(&roleRig, "rig", "", "Rig name (required for rig-specific roles)") @@ -526,3 +544,83 @@ func runRoleEnv(cmd *cobra.Command, args []string) error { return nil } + +func runRoleDef(cmd *cobra.Command, args []string) error { + roleName := args[0] + + // Validate role name + validRoles := config.AllRoles() + isValid := false + for _, r := range validRoles { + if r == roleName { + isValid = true + break + } + } + if !isValid { + return fmt.Errorf("unknown role %q - valid roles: %s", roleName, strings.Join(validRoles, ", ")) + } + + // Determine town root and rig path + townRoot, _ := workspace.FindFromCwd() + rigPath := "" + if townRoot != "" { + // Try to get rig path if we're in a rig directory + if rigInfo, err := GetRole(); err == nil && rigInfo.Rig != "" { + rigPath = filepath.Join(townRoot, rigInfo.Rig) + } + } + + // Load role definition with overrides + def, err := config.LoadRoleDefinition(townRoot, rigPath, roleName) + if err != nil { + return fmt.Errorf("loading role definition: %w", err) + } + + // Display role info + fmt.Printf("%s %s\n", style.Bold.Render("Role:"), def.Role) + fmt.Printf("%s %s\n", style.Bold.Render("Scope:"), def.Scope) + fmt.Println() + + // Session config + fmt.Println(style.Bold.Render("[session]")) + fmt.Printf(" pattern = %q\n", def.Session.Pattern) + fmt.Printf(" work_dir = %q\n", def.Session.WorkDir) + fmt.Printf(" needs_pre_sync = %v\n", def.Session.NeedsPreSync) + if def.Session.StartCommand != "" { + fmt.Printf(" start_command = %q\n", def.Session.StartCommand) + } + fmt.Println() + + // Environment variables + if len(def.Env) > 0 { + fmt.Println(style.Bold.Render("[env]")) + envKeys := make([]string, 0, len(def.Env)) + for k := range def.Env { + envKeys = append(envKeys, k) + } + sort.Strings(envKeys) + for _, k := range envKeys { + fmt.Printf(" %s = %q\n", k, def.Env[k]) + } + fmt.Println() + } + + // Health config + fmt.Println(style.Bold.Render("[health]")) + fmt.Printf(" ping_timeout = %q\n", def.Health.PingTimeout.String()) + fmt.Printf(" consecutive_failures = %d\n", def.Health.ConsecutiveFailures) + fmt.Printf(" kill_cooldown = %q\n", def.Health.KillCooldown.String()) + fmt.Printf(" stuck_threshold = %q\n", def.Health.StuckThreshold.String()) + fmt.Println() + + // Prompts + if def.Nudge != "" { + fmt.Printf("%s %s\n", style.Bold.Render("Nudge:"), def.Nudge) + } + if def.PromptTemplate != "" { + fmt.Printf("%s %s\n", style.Bold.Render("Template:"), def.PromptTemplate) + } + + return nil +} diff --git a/internal/config/roles.go b/internal/config/roles.go new file mode 100644 index 00000000..7b121f6d --- /dev/null +++ b/internal/config/roles.go @@ -0,0 +1,298 @@ +// Package config provides role configuration for Gas Town agents. +package config + +import ( + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/BurntSushi/toml" +) + +//go:embed roles/*.toml +var defaultRolesFS embed.FS + +// RoleDefinition contains all configuration for a role type. +// This replaces the role bead system with config files. +type RoleDefinition struct { + // Role is the role identifier (mayor, deacon, witness, refinery, polecat, crew, dog). + Role string `toml:"role"` + + // Scope is "town" or "rig" - determines where the agent runs. + Scope string `toml:"scope"` + + // Session contains tmux session configuration. + Session RoleSessionConfig `toml:"session"` + + // Env contains environment variables to set in the session. + Env map[string]string `toml:"env,omitempty"` + + // Health contains health check configuration. + Health RoleHealthConfig `toml:"health"` + + // Nudge is the initial prompt sent when starting the agent. + Nudge string `toml:"nudge,omitempty"` + + // PromptTemplate is the name of the role's prompt template file. + PromptTemplate string `toml:"prompt_template,omitempty"` +} + +// RoleSessionConfig contains session-related configuration. +type RoleSessionConfig struct { + // Pattern is the tmux session name pattern. + // Supports placeholders: {rig}, {name}, {role} + // Examples: "hq-mayor", "gt-{rig}-witness", "gt-{rig}-{name}" + Pattern string `toml:"pattern"` + + // WorkDir is the working directory pattern. + // Supports placeholders: {town}, {rig}, {name}, {role} + // Examples: "{town}", "{town}/{rig}/witness" + WorkDir string `toml:"work_dir"` + + // NeedsPreSync indicates if workspace needs git sync before starting. + NeedsPreSync bool `toml:"needs_pre_sync"` + + // StartCommand is the command to run after creating the session. + // Default: "exec claude --dangerously-skip-permissions" + StartCommand string `toml:"start_command,omitempty"` +} + +// RoleHealthConfig contains health check thresholds. +type RoleHealthConfig struct { + // PingTimeout is how long to wait for a health check response. + PingTimeout Duration `toml:"ping_timeout"` + + // ConsecutiveFailures is how many failed health checks before force-kill. + ConsecutiveFailures int `toml:"consecutive_failures"` + + // KillCooldown is the minimum time between force-kills. + KillCooldown Duration `toml:"kill_cooldown"` + + // StuckThreshold is how long a wisp can be in_progress before considered stuck. + StuckThreshold Duration `toml:"stuck_threshold"` +} + +// Duration is a wrapper for time.Duration that supports TOML marshaling. +type Duration struct { + time.Duration +} + +// UnmarshalText implements encoding.TextUnmarshaler for Duration. +func (d *Duration) UnmarshalText(text []byte) error { + parsed, err := time.ParseDuration(string(text)) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", string(text), err) + } + d.Duration = parsed + return nil +} + +// MarshalText implements encoding.TextMarshaler for Duration. +func (d Duration) MarshalText() ([]byte, error) { + return []byte(d.Duration.String()), nil +} + +// String returns the duration as a string. +func (d Duration) String() string { + return d.Duration.String() +} + +// AllRoles returns the list of all known role names. +func AllRoles() []string { + return []string{"mayor", "deacon", "dog", "witness", "refinery", "polecat", "crew"} +} + +// TownRoles returns roles that operate at town scope. +func TownRoles() []string { + return []string{"mayor", "deacon", "dog"} +} + +// RigRoles returns roles that operate at rig scope. +func RigRoles() []string { + return []string{"witness", "refinery", "polecat", "crew"} +} + +// isValidRoleName checks if the given name is a known role. +func isValidRoleName(name string) bool { + for _, r := range AllRoles() { + if r == name { + return true + } + } + return false +} + +// LoadRoleDefinition loads role configuration with override resolution. +// Resolution order (later overrides earlier): +// 1. Built-in defaults (embedded in binary) +// 2. Town-level overrides (/roles/.toml) +// 3. Rig-level overrides (/roles/.toml) +// +// Each layer merges with (not replaces) the previous. Users only specify +// fields they want to change. +func LoadRoleDefinition(townRoot, rigPath, roleName string) (*RoleDefinition, error) { + // Validate role name + if !isValidRoleName(roleName) { + return nil, fmt.Errorf("unknown role %q - valid roles: %v", roleName, AllRoles()) + } + + // 1. Load built-in defaults + def, err := loadBuiltinRoleDefinition(roleName) + if err != nil { + return nil, fmt.Errorf("loading built-in role %s: %w", roleName, err) + } + + // 2. Apply town-level overrides if present + townOverridePath := filepath.Join(townRoot, "roles", roleName+".toml") + if override, err := loadRoleOverride(townOverridePath); err == nil { + mergeRoleDefinition(def, override) + } + + // 3. Apply rig-level overrides if present (only for rig-scoped roles) + if rigPath != "" { + rigOverridePath := filepath.Join(rigPath, "roles", roleName+".toml") + if override, err := loadRoleOverride(rigOverridePath); err == nil { + mergeRoleDefinition(def, override) + } + } + + return def, nil +} + +// loadBuiltinRoleDefinition loads a role definition from embedded defaults. +func loadBuiltinRoleDefinition(roleName string) (*RoleDefinition, error) { + data, err := defaultRolesFS.ReadFile("roles/" + roleName + ".toml") + if err != nil { + return nil, fmt.Errorf("role %s not found in defaults: %w", roleName, err) + } + + var def RoleDefinition + if err := toml.Unmarshal(data, &def); err != nil { + return nil, fmt.Errorf("parsing role %s: %w", roleName, err) + } + + return &def, nil +} + +// loadRoleOverride loads a role override from a file path. +// Returns nil, nil if file doesn't exist. +func loadRoleOverride(path string) (*RoleDefinition, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, err // Signal no override exists + } + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + var def RoleDefinition + if err := toml.Unmarshal(data, &def); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + return &def, nil +} + +// mergeRoleDefinition merges override into base. +// Only non-zero values in override are applied. +func mergeRoleDefinition(base, override *RoleDefinition) { + if override == nil { + return + } + + // Role and Scope are immutable + // (can't change a witness to a mayor via override) + + // Session config + if override.Session.Pattern != "" { + base.Session.Pattern = override.Session.Pattern + } + if override.Session.WorkDir != "" { + base.Session.WorkDir = override.Session.WorkDir + } + // NeedsPreSync can only be enabled via override, not disabled. + // This is intentional: if a role's builtin requires pre-sync (e.g., refinery), + // disabling it would break the role's assumptions about workspace state. + if override.Session.NeedsPreSync { + base.Session.NeedsPreSync = true + } + if override.Session.StartCommand != "" { + base.Session.StartCommand = override.Session.StartCommand + } + + // Env vars (merge, don't replace) + if override.Env != nil { + if base.Env == nil { + base.Env = make(map[string]string) + } + for k, v := range override.Env { + base.Env[k] = v + } + } + + // Health config + if override.Health.PingTimeout.Duration != 0 { + base.Health.PingTimeout = override.Health.PingTimeout + } + if override.Health.ConsecutiveFailures != 0 { + base.Health.ConsecutiveFailures = override.Health.ConsecutiveFailures + } + if override.Health.KillCooldown.Duration != 0 { + base.Health.KillCooldown = override.Health.KillCooldown + } + if override.Health.StuckThreshold.Duration != 0 { + base.Health.StuckThreshold = override.Health.StuckThreshold + } + + // Prompts + if override.Nudge != "" { + base.Nudge = override.Nudge + } + if override.PromptTemplate != "" { + base.PromptTemplate = override.PromptTemplate + } +} + +// ExpandPattern expands placeholders in a pattern string. +// Supported placeholders: {town}, {rig}, {name}, {role} +func ExpandPattern(pattern, townRoot, rig, name, role string) string { + result := pattern + result = strings.ReplaceAll(result, "{town}", townRoot) + result = strings.ReplaceAll(result, "{rig}", rig) + result = strings.ReplaceAll(result, "{name}", name) + result = strings.ReplaceAll(result, "{role}", role) + return result +} + +// ToLegacyRoleConfig converts a RoleDefinition to the legacy RoleConfig format +// for backward compatibility with existing daemon code. +func (rd *RoleDefinition) ToLegacyRoleConfig() *LegacyRoleConfig { + return &LegacyRoleConfig{ + SessionPattern: rd.Session.Pattern, + WorkDirPattern: rd.Session.WorkDir, + NeedsPreSync: rd.Session.NeedsPreSync, + StartCommand: rd.Session.StartCommand, + EnvVars: rd.Env, + PingTimeout: rd.Health.PingTimeout.String(), + ConsecutiveFailures: rd.Health.ConsecutiveFailures, + KillCooldown: rd.Health.KillCooldown.String(), + StuckThreshold: rd.Health.StuckThreshold.String(), + } +} + +// LegacyRoleConfig matches the old beads.RoleConfig struct for compatibility. +// This allows gradual migration without breaking existing code. +type LegacyRoleConfig struct { + SessionPattern string + WorkDirPattern string + NeedsPreSync bool + StartCommand string + EnvVars map[string]string + PingTimeout string + ConsecutiveFailures int + KillCooldown string + StuckThreshold string +} diff --git a/internal/config/roles/crew.toml b/internal/config/roles/crew.toml new file mode 100644 index 00000000..168dd5df --- /dev/null +++ b/internal/config/roles/crew.toml @@ -0,0 +1,23 @@ +# Crew role definition +# Persistent user-managed workspaces. Multiple per rig. + +role = "crew" +scope = "rig" +nudge = "Check your hook and mail, then act accordingly." +prompt_template = "crew.md.tmpl" + +[session] +pattern = "gt-{rig}-crew-{name}" +work_dir = "{town}/{rig}/crew/{name}" +needs_pre_sync = true +start_command = "exec claude --dangerously-skip-permissions" + +[env] +GT_ROLE = "crew" +GT_SCOPE = "rig" + +[health] +ping_timeout = "30s" +consecutive_failures = 3 +kill_cooldown = "5m" +stuck_threshold = "4h" diff --git a/internal/config/roles/deacon.toml b/internal/config/roles/deacon.toml new file mode 100644 index 00000000..fb0593f8 --- /dev/null +++ b/internal/config/roles/deacon.toml @@ -0,0 +1,23 @@ +# Deacon role definition +# Daemon beacon for heartbeats and monitoring. One per town. + +role = "deacon" +scope = "town" +nudge = "Run 'gt prime' to check patrol status and begin heartbeat cycle." +prompt_template = "deacon.md.tmpl" + +[session] +pattern = "hq-deacon" +work_dir = "{town}" +needs_pre_sync = false +start_command = "exec claude --dangerously-skip-permissions" + +[env] +GT_ROLE = "deacon" +GT_SCOPE = "town" + +[health] +ping_timeout = "30s" +consecutive_failures = 3 +kill_cooldown = "5m" +stuck_threshold = "1h" diff --git a/internal/config/roles/dog.toml b/internal/config/roles/dog.toml new file mode 100644 index 00000000..d2e24d55 --- /dev/null +++ b/internal/config/roles/dog.toml @@ -0,0 +1,23 @@ +# Dog role definition +# Town-level workers for cross-rig tasks. Dispatched by Deacon. + +role = "dog" +scope = "town" +nudge = "Check your hook for work assignments." +prompt_template = "dog.md.tmpl" + +[session] +pattern = "gt-dog-{name}" +work_dir = "{town}/deacon/dogs/{name}" +needs_pre_sync = false +start_command = "exec claude --dangerously-skip-permissions" + +[env] +GT_ROLE = "dog" +GT_SCOPE = "town" + +[health] +ping_timeout = "30s" +consecutive_failures = 3 +kill_cooldown = "5m" +stuck_threshold = "2h" diff --git a/internal/config/roles/mayor.toml b/internal/config/roles/mayor.toml new file mode 100644 index 00000000..9aa272be --- /dev/null +++ b/internal/config/roles/mayor.toml @@ -0,0 +1,23 @@ +# Mayor role definition +# Global coordinator for cross-rig work. One per town. + +role = "mayor" +scope = "town" +nudge = "Check mail and hook status, then act accordingly." +prompt_template = "mayor.md.tmpl" + +[session] +pattern = "hq-mayor" +work_dir = "{town}" +needs_pre_sync = false +start_command = "exec claude --dangerously-skip-permissions" + +[env] +GT_ROLE = "mayor" +GT_SCOPE = "town" + +[health] +ping_timeout = "30s" +consecutive_failures = 3 +kill_cooldown = "5m" +stuck_threshold = "1h" diff --git a/internal/config/roles/polecat.toml b/internal/config/roles/polecat.toml new file mode 100644 index 00000000..dbf50f92 --- /dev/null +++ b/internal/config/roles/polecat.toml @@ -0,0 +1,23 @@ +# Polecat role definition +# Ephemeral workers for batch work dispatch. Multiple per rig. + +role = "polecat" +scope = "rig" +nudge = "Check your hook for work assignments." +prompt_template = "polecat.md.tmpl" + +[session] +pattern = "gt-{rig}-{name}" +work_dir = "{town}/{rig}/polecats/{name}" +needs_pre_sync = true +start_command = "exec claude --dangerously-skip-permissions" + +[env] +GT_ROLE = "polecat" +GT_SCOPE = "rig" + +[health] +ping_timeout = "30s" +consecutive_failures = 3 +kill_cooldown = "5m" +stuck_threshold = "2h" diff --git a/internal/config/roles/refinery.toml b/internal/config/roles/refinery.toml new file mode 100644 index 00000000..be684f2f --- /dev/null +++ b/internal/config/roles/refinery.toml @@ -0,0 +1,23 @@ +# Refinery role definition +# Merge queue processor with verification gates. One per rig. + +role = "refinery" +scope = "rig" +nudge = "Run 'gt prime' to check merge queue and begin processing." +prompt_template = "refinery.md.tmpl" + +[session] +pattern = "gt-{rig}-refinery" +work_dir = "{town}/{rig}/refinery/rig" +needs_pre_sync = true +start_command = "exec claude --dangerously-skip-permissions" + +[env] +GT_ROLE = "refinery" +GT_SCOPE = "rig" + +[health] +ping_timeout = "30s" +consecutive_failures = 3 +kill_cooldown = "5m" +stuck_threshold = "2h" diff --git a/internal/config/roles/witness.toml b/internal/config/roles/witness.toml new file mode 100644 index 00000000..d68ac205 --- /dev/null +++ b/internal/config/roles/witness.toml @@ -0,0 +1,23 @@ +# Witness role definition +# Per-rig worker monitor with progressive nudging. One per rig. + +role = "witness" +scope = "rig" +nudge = "Run 'gt prime' to check worker status and begin patrol cycle." +prompt_template = "witness.md.tmpl" + +[session] +pattern = "gt-{rig}-witness" +work_dir = "{town}/{rig}/witness" +needs_pre_sync = false +start_command = "exec claude --dangerously-skip-permissions" + +[env] +GT_ROLE = "witness" +GT_SCOPE = "rig" + +[health] +ping_timeout = "30s" +consecutive_failures = 3 +kill_cooldown = "5m" +stuck_threshold = "1h" diff --git a/internal/config/roles_test.go b/internal/config/roles_test.go new file mode 100644 index 00000000..bf79f33d --- /dev/null +++ b/internal/config/roles_test.go @@ -0,0 +1,272 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +func TestLoadBuiltinRoleDefinition(t *testing.T) { + tests := []struct { + name string + role string + wantScope string + wantPattern string + wantPreSync bool + }{ + { + name: "mayor", + role: "mayor", + wantScope: "town", + wantPattern: "hq-mayor", + wantPreSync: false, + }, + { + name: "deacon", + role: "deacon", + wantScope: "town", + wantPattern: "hq-deacon", + wantPreSync: false, + }, + { + name: "witness", + role: "witness", + wantScope: "rig", + wantPattern: "gt-{rig}-witness", + wantPreSync: false, + }, + { + name: "refinery", + role: "refinery", + wantScope: "rig", + wantPattern: "gt-{rig}-refinery", + wantPreSync: true, + }, + { + name: "polecat", + role: "polecat", + wantScope: "rig", + wantPattern: "gt-{rig}-{name}", + wantPreSync: true, + }, + { + name: "crew", + role: "crew", + wantScope: "rig", + wantPattern: "gt-{rig}-crew-{name}", + wantPreSync: true, + }, + { + name: "dog", + role: "dog", + wantScope: "town", + wantPattern: "gt-dog-{name}", + wantPreSync: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + def, err := loadBuiltinRoleDefinition(tt.role) + if err != nil { + t.Fatalf("loadBuiltinRoleDefinition(%s) error: %v", tt.role, err) + } + + if def.Role != tt.role { + t.Errorf("Role = %q, want %q", def.Role, tt.role) + } + if def.Scope != tt.wantScope { + t.Errorf("Scope = %q, want %q", def.Scope, tt.wantScope) + } + if def.Session.Pattern != tt.wantPattern { + t.Errorf("Session.Pattern = %q, want %q", def.Session.Pattern, tt.wantPattern) + } + if def.Session.NeedsPreSync != tt.wantPreSync { + t.Errorf("Session.NeedsPreSync = %v, want %v", def.Session.NeedsPreSync, tt.wantPreSync) + } + + // Verify health config has reasonable defaults + if def.Health.PingTimeout.Duration == 0 { + t.Error("Health.PingTimeout should not be zero") + } + if def.Health.ConsecutiveFailures == 0 { + t.Error("Health.ConsecutiveFailures should not be zero") + } + }) + } +} + +func TestLoadBuiltinRoleDefinition_UnknownRole(t *testing.T) { + _, err := loadBuiltinRoleDefinition("nonexistent") + if err == nil { + t.Error("expected error for unknown role, got nil") + } +} + +func TestLoadRoleDefinition_UnknownRole(t *testing.T) { + _, err := LoadRoleDefinition("/tmp/town", "", "nonexistent") + if err == nil { + t.Error("expected error for unknown role, got nil") + } + // Should have a clear error message, not a cryptic embed error + if !strings.Contains(err.Error(), "unknown role") { + t.Errorf("error should mention 'unknown role', got: %v", err) + } +} + +func TestAllRoles(t *testing.T) { + roles := AllRoles() + if len(roles) != 7 { + t.Errorf("AllRoles() returned %d roles, want 7", len(roles)) + } + + expected := map[string]bool{ + "mayor": true, + "deacon": true, + "dog": true, + "witness": true, + "refinery": true, + "polecat": true, + "crew": true, + } + + for _, r := range roles { + if !expected[r] { + t.Errorf("unexpected role %q in AllRoles()", r) + } + } +} + +func TestTownRoles(t *testing.T) { + roles := TownRoles() + if len(roles) != 3 { + t.Errorf("TownRoles() returned %d roles, want 3", len(roles)) + } + + for _, r := range roles { + def, err := loadBuiltinRoleDefinition(r) + if err != nil { + t.Fatalf("loadBuiltinRoleDefinition(%s) error: %v", r, err) + } + if def.Scope != "town" { + t.Errorf("role %s has scope %q, expected 'town'", r, def.Scope) + } + } +} + +func TestRigRoles(t *testing.T) { + roles := RigRoles() + if len(roles) != 4 { + t.Errorf("RigRoles() returned %d roles, want 4", len(roles)) + } + + for _, r := range roles { + def, err := loadBuiltinRoleDefinition(r) + if err != nil { + t.Fatalf("loadBuiltinRoleDefinition(%s) error: %v", r, err) + } + if def.Scope != "rig" { + t.Errorf("role %s has scope %q, expected 'rig'", r, def.Scope) + } + } +} + +func TestExpandPattern(t *testing.T) { + tests := []struct { + pattern string + town string + rig string + name string + role string + expected string + }{ + { + pattern: "{town}", + town: "/home/user/gt", + expected: "/home/user/gt", + }, + { + pattern: "gt-{rig}-witness", + rig: "gastown", + expected: "gt-gastown-witness", + }, + { + pattern: "{town}/{rig}/crew/{name}", + town: "/home/user/gt", + rig: "gastown", + name: "max", + expected: "/home/user/gt/gastown/crew/max", + }, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + got := ExpandPattern(tt.pattern, tt.town, tt.rig, tt.name, tt.role) + if got != tt.expected { + t.Errorf("ExpandPattern() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestDuration_UnmarshalText(t *testing.T) { + tests := []struct { + input string + expected time.Duration + }{ + {"30s", 30 * time.Second}, + {"5m", 5 * time.Minute}, + {"1h", time.Hour}, + {"1h30m", time.Hour + 30*time.Minute}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + var d Duration + if err := d.UnmarshalText([]byte(tt.input)); err != nil { + t.Fatalf("UnmarshalText() error: %v", err) + } + if d.Duration != tt.expected { + t.Errorf("Duration = %v, want %v", d.Duration, tt.expected) + } + }) + } +} + +func TestToLegacyRoleConfig(t *testing.T) { + def := &RoleDefinition{ + Role: "witness", + Scope: "rig", + Session: RoleSessionConfig{ + Pattern: "gt-{rig}-witness", + WorkDir: "{town}/{rig}/witness", + NeedsPreSync: false, + StartCommand: "exec claude", + }, + Env: map[string]string{"GT_ROLE": "witness"}, + Health: RoleHealthConfig{ + PingTimeout: Duration{30 * time.Second}, + ConsecutiveFailures: 3, + KillCooldown: Duration{5 * time.Minute}, + StuckThreshold: Duration{time.Hour}, + }, + } + + legacy := def.ToLegacyRoleConfig() + + if legacy.SessionPattern != "gt-{rig}-witness" { + t.Errorf("SessionPattern = %q, want %q", legacy.SessionPattern, "gt-{rig}-witness") + } + if legacy.WorkDirPattern != "{town}/{rig}/witness" { + t.Errorf("WorkDirPattern = %q, want %q", legacy.WorkDirPattern, "{town}/{rig}/witness") + } + if legacy.NeedsPreSync != false { + t.Errorf("NeedsPreSync = %v, want false", legacy.NeedsPreSync) + } + if legacy.PingTimeout != "30s" { + t.Errorf("PingTimeout = %q, want %q", legacy.PingTimeout, "30s") + } + if legacy.ConsecutiveFailures != 3 { + t.Errorf("ConsecutiveFailures = %d, want 3", legacy.ConsecutiveFailures) + } +}