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