feat(roles): add config-based role definition system (Phase 1)
Replace role beads with embedded TOML config files for role definitions. This is Phase 1 of gt-y1uvb - adds the config infrastructure without yet switching the daemon to use it. New files: - internal/config/roles.go: RoleDefinition types, LoadRoleDefinition() with layered override resolution (builtin → town → rig) - internal/config/roles/*.toml: 7 embedded role definitions - internal/config/roles_test.go: unit tests New command: - gt role def <role>: displays effective role configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
b8eb936219
commit
544cacf36d
@@ -100,6 +100,23 @@ Examples:
|
||||
RunE: runRoleEnv,
|
||||
}
|
||||
|
||||
var roleDefCmd = &cobra.Command{
|
||||
Use: "def <role>",
|
||||
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/<role>.toml)
|
||||
3. Rig-level overrides (<rig>/roles/<role>.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
|
||||
}
|
||||
|
||||
298
internal/config/roles.go
Normal file
298
internal/config/roles.go
Normal file
@@ -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 (<town>/roles/<role>.toml)
|
||||
// 3. Rig-level overrides (<rig>/roles/<role>.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
|
||||
}
|
||||
23
internal/config/roles/crew.toml
Normal file
23
internal/config/roles/crew.toml
Normal file
@@ -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"
|
||||
23
internal/config/roles/deacon.toml
Normal file
23
internal/config/roles/deacon.toml
Normal file
@@ -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"
|
||||
23
internal/config/roles/dog.toml
Normal file
23
internal/config/roles/dog.toml
Normal file
@@ -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"
|
||||
23
internal/config/roles/mayor.toml
Normal file
23
internal/config/roles/mayor.toml
Normal file
@@ -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"
|
||||
23
internal/config/roles/polecat.toml
Normal file
23
internal/config/roles/polecat.toml
Normal file
@@ -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"
|
||||
23
internal/config/roles/refinery.toml
Normal file
23
internal/config/roles/refinery.toml
Normal file
@@ -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"
|
||||
23
internal/config/roles/witness.toml
Normal file
23
internal/config/roles/witness.toml
Normal file
@@ -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"
|
||||
272
internal/config/roles_test.go
Normal file
272
internal/config/roles_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user