feat(roles): switch daemon to config-based roles, remove role beads (Phase 2+3)
Phase 2: Daemon now uses config.LoadRoleDefinition() instead of role beads - lifecycle.go: getRoleConfigForIdentity() reads from TOML configs - Layered override resolution: builtin → town → rig Phase 3: Remove role bead creation and references - Remove RoleBead field from AgentFields struct - gt install no longer creates role beads - Remove 'role' from custom types list - Delete migrate_agents.go (no longer needed) - Deprecate beads_role.go (kept for reading existing beads) - Rewrite role_beads_check.go to validate TOML configs Existing role beads are orphaned but harmless. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
544cacf36d
commit
a610283078
@@ -211,7 +211,7 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error {
|
||||
}
|
||||
|
||||
// ParsedIdentity holds the components extracted from an agent identity string.
|
||||
// This is used to look up the appropriate role bead for lifecycle config.
|
||||
// This is used to look up the appropriate role config for lifecycle management.
|
||||
type ParsedIdentity struct {
|
||||
RoleType string // mayor, deacon, witness, refinery, crew, polecat
|
||||
RigName string // Empty for town-level agents (mayor, deacon)
|
||||
@@ -220,7 +220,7 @@ type ParsedIdentity struct {
|
||||
|
||||
// parseIdentity extracts role type, rig name, and agent name from an identity string.
|
||||
// This is the ONLY place where identity string patterns are parsed.
|
||||
// All other functions should use the extracted components to look up role beads.
|
||||
// All other functions should use the extracted components to look up role config.
|
||||
func parseIdentity(identity string) (*ParsedIdentity, error) {
|
||||
switch identity {
|
||||
case "mayor":
|
||||
@@ -268,49 +268,50 @@ func parseIdentity(identity string) (*ParsedIdentity, error) {
|
||||
return nil, fmt.Errorf("unknown identity format: %s", identity)
|
||||
}
|
||||
|
||||
// getRoleConfigForIdentity looks up the role bead for an identity and returns its config.
|
||||
// Falls back to default config if role bead doesn't exist or has no config.
|
||||
// getRoleConfigForIdentity loads role configuration from the config-based role system.
|
||||
// Uses config.LoadRoleDefinition() with layered override resolution (builtin → town → rig).
|
||||
// Returns config in beads.RoleConfig format for backward compatibility.
|
||||
func (d *Daemon) getRoleConfigForIdentity(identity string) (*beads.RoleConfig, *ParsedIdentity, error) {
|
||||
parsed, err := parseIdentity(identity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Look up role bead
|
||||
b := beads.New(d.config.TownRoot)
|
||||
// Determine rig path for rig-scoped roles
|
||||
rigPath := ""
|
||||
if parsed.RigName != "" {
|
||||
rigPath = filepath.Join(d.config.TownRoot, parsed.RigName)
|
||||
}
|
||||
|
||||
roleBeadID := beads.RoleBeadIDTown(parsed.RoleType)
|
||||
roleConfig, err := b.GetRoleConfig(roleBeadID)
|
||||
// Load role definition from config system (Phase 2: config-based roles)
|
||||
roleDef, err := config.LoadRoleDefinition(d.config.TownRoot, rigPath, parsed.RoleType)
|
||||
if err != nil {
|
||||
d.logger.Printf("Warning: failed to get role config for %s: %v", roleBeadID, err)
|
||||
d.logger.Printf("Warning: failed to load role definition for %s: %v", parsed.RoleType, err)
|
||||
// Return parsed identity even if config fails (caller can use defaults)
|
||||
return nil, parsed, nil
|
||||
}
|
||||
|
||||
// Backward compatibility: fall back to legacy role bead IDs.
|
||||
if roleConfig == nil {
|
||||
legacyRoleBeadID := beads.RoleBeadID(parsed.RoleType) // gt-<role>-role
|
||||
if legacyRoleBeadID != roleBeadID {
|
||||
legacyCfg, legacyErr := b.GetRoleConfig(legacyRoleBeadID)
|
||||
if legacyErr != nil {
|
||||
d.logger.Printf("Warning: failed to get legacy role config for %s: %v", legacyRoleBeadID, legacyErr)
|
||||
} else if legacyCfg != nil {
|
||||
roleConfig = legacyCfg
|
||||
}
|
||||
}
|
||||
// Convert to beads.RoleConfig for backward compatibility
|
||||
roleConfig := &beads.RoleConfig{
|
||||
SessionPattern: roleDef.Session.Pattern,
|
||||
WorkDirPattern: roleDef.Session.WorkDir,
|
||||
NeedsPreSync: roleDef.Session.NeedsPreSync,
|
||||
StartCommand: roleDef.Session.StartCommand,
|
||||
EnvVars: roleDef.Env,
|
||||
}
|
||||
|
||||
// Return parsed identity even if config is nil (caller can use defaults)
|
||||
return roleConfig, parsed, nil
|
||||
}
|
||||
|
||||
// identityToSession converts a beads identity to a tmux session name.
|
||||
// Uses role bead config if available, falls back to hardcoded patterns.
|
||||
// Uses role config if available, falls back to hardcoded patterns.
|
||||
func (d *Daemon) identityToSession(identity string) string {
|
||||
config, parsed, err := d.getRoleConfigForIdentity(identity)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If role bead has session_pattern, use it
|
||||
// If role config has session_pattern, use it
|
||||
if config != nil && config.SessionPattern != "" {
|
||||
return beads.ExpandRolePattern(config.SessionPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
|
||||
}
|
||||
@@ -333,7 +334,7 @@ func (d *Daemon) identityToSession(identity string) string {
|
||||
}
|
||||
|
||||
// restartSession starts a new session for the given agent.
|
||||
// Uses role bead config if available, falls back to hardcoded defaults.
|
||||
// Uses role config if available, falls back to hardcoded defaults.
|
||||
func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
// Get role config for this identity
|
||||
config, parsed, err := d.getRoleConfigForIdentity(identity)
|
||||
@@ -409,9 +410,9 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
}
|
||||
|
||||
// getWorkDir determines the working directory for an agent.
|
||||
// Uses role bead config if available, falls back to hardcoded defaults.
|
||||
// Uses role config if available, falls back to hardcoded defaults.
|
||||
func (d *Daemon) getWorkDir(config *beads.RoleConfig, parsed *ParsedIdentity) string {
|
||||
// If role bead has work_dir_pattern, use it
|
||||
// If role config has work_dir_pattern, use it
|
||||
if config != nil && config.WorkDirPattern != "" {
|
||||
return beads.ExpandRolePattern(config.WorkDirPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
|
||||
}
|
||||
@@ -442,9 +443,9 @@ func (d *Daemon) getWorkDir(config *beads.RoleConfig, parsed *ParsedIdentity) st
|
||||
}
|
||||
|
||||
// getNeedsPreSync determines if a workspace needs git sync before starting.
|
||||
// Uses role bead config if available, falls back to hardcoded defaults.
|
||||
// Uses role config if available, falls back to hardcoded defaults.
|
||||
func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentity) bool {
|
||||
// If role bead has explicit config, use it
|
||||
// If role config is available, use it
|
||||
if config != nil {
|
||||
return config.NeedsPreSync
|
||||
}
|
||||
@@ -459,9 +460,9 @@ func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentit
|
||||
}
|
||||
|
||||
// getStartCommand determines the startup command for an agent.
|
||||
// Uses role bead config if available, then role-based agent selection, then hardcoded defaults.
|
||||
// Uses role config if available, then role-based agent selection, then hardcoded defaults.
|
||||
func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIdentity) string {
|
||||
// If role bead has explicit config, use it
|
||||
// If role config is available, use it
|
||||
if roleConfig != nil && roleConfig.StartCommand != "" {
|
||||
// Expand any patterns in the command
|
||||
return beads.ExpandRolePattern(roleConfig.StartCommand, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
|
||||
@@ -516,7 +517,7 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
|
||||
}
|
||||
|
||||
// setSessionEnvironment sets environment variables for the tmux session.
|
||||
// Uses centralized AgentEnv for consistency, plus role bead custom env vars if available.
|
||||
// Uses centralized AgentEnv for consistency, plus custom env vars from role config if available.
|
||||
func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.RoleConfig, parsed *ParsedIdentity) {
|
||||
// Use centralized AgentEnv for base environment variables
|
||||
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||
@@ -529,7 +530,7 @@ func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.Rol
|
||||
_ = d.tmux.SetEnvironment(sessionName, k, v)
|
||||
}
|
||||
|
||||
// Set any custom env vars from role config (bead-defined overrides)
|
||||
// Set any custom env vars from role config
|
||||
if roleConfig != nil {
|
||||
for k, v := range roleConfig.EnvVars {
|
||||
expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
|
||||
@@ -637,10 +638,10 @@ type AgentBeadInfo struct {
|
||||
Type string `json:"issue_type"`
|
||||
State string // Parsed from description: agent_state
|
||||
HookBead string // Parsed from description: hook_bead
|
||||
RoleBead string // Parsed from description: role_bead
|
||||
RoleType string // Parsed from description: role_type
|
||||
Rig string // Parsed from description: rig
|
||||
LastUpdate string `json:"updated_at"`
|
||||
// Note: RoleBead field removed - role definitions are now config-based
|
||||
}
|
||||
|
||||
// getAgentBeadState reads non-observable agent state from an agent bead.
|
||||
@@ -699,7 +700,6 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
|
||||
|
||||
if fields != nil {
|
||||
info.State = fields.AgentState
|
||||
info.RoleBead = fields.RoleBead
|
||||
info.RoleType = fields.RoleType
|
||||
info.Rig = fields.Rig
|
||||
}
|
||||
|
||||
@@ -5,41 +5,60 @@ package daemon
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runBd(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("bd", args...) //nolint:gosec // bd is a trusted internal tool in this repo
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("bd %s failed: %v\n%s", strings.Join(args, " "), err, string(out))
|
||||
// TestGetRoleConfigForIdentity_UsesBuiltinDefaults tests that the daemon
|
||||
// uses built-in role definitions from embedded TOML files when no overrides exist.
|
||||
func TestGetRoleConfigForIdentity_UsesBuiltinDefaults(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
|
||||
d := &Daemon{
|
||||
config: &Config{TownRoot: townRoot},
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
}
|
||||
|
||||
// Should load witness role from built-in defaults
|
||||
cfg, parsed, err := d.getRoleConfigForIdentity("myrig-witness")
|
||||
if err != nil {
|
||||
t.Fatalf("getRoleConfigForIdentity: %v", err)
|
||||
}
|
||||
if parsed == nil || parsed.RoleType != "witness" {
|
||||
t.Fatalf("parsed = %#v, want roleType witness", parsed)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("cfg is nil, expected built-in defaults")
|
||||
}
|
||||
// Built-in witness has session pattern "gt-{rig}-witness"
|
||||
if cfg.SessionPattern != "gt-{rig}-witness" {
|
||||
t.Errorf("cfg.SessionPattern = %q, want %q", cfg.SessionPattern, "gt-{rig}-witness")
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestGetRoleConfigForIdentity_PrefersTownRoleBead(t *testing.T) {
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
t.Skip("bd not installed")
|
||||
// TestGetRoleConfigForIdentity_TownOverride tests that town-level TOML overrides
|
||||
// are merged with built-in defaults.
|
||||
func TestGetRoleConfigForIdentity_TownOverride(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
|
||||
// Create town-level override
|
||||
rolesDir := filepath.Join(townRoot, "roles")
|
||||
if err := os.MkdirAll(rolesDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir roles: %v", err)
|
||||
}
|
||||
|
||||
townRoot := t.TempDir()
|
||||
runBd(t, townRoot, "init", "--quiet", "--prefix", "hq")
|
||||
// Override start_command for witness role
|
||||
witnessOverride := `
|
||||
role = "witness"
|
||||
scope = "rig"
|
||||
|
||||
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event")
|
||||
|
||||
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event")
|
||||
|
||||
// Create canonical role bead.
|
||||
runBd(t, townRoot, "create",
|
||||
"--id", "hq-witness-role",
|
||||
"--type", "role",
|
||||
"--title", "Witness Role",
|
||||
"--description", "start_command: exec echo hq\n",
|
||||
)
|
||||
[session]
|
||||
start_command = "exec echo custom-town-command"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte(witnessOverride), 0644); err != nil {
|
||||
t.Fatalf("write witness.toml: %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
config: &Config{TownRoot: townRoot},
|
||||
@@ -53,30 +72,56 @@ func TestGetRoleConfigForIdentity_PrefersTownRoleBead(t *testing.T) {
|
||||
if parsed == nil || parsed.RoleType != "witness" {
|
||||
t.Fatalf("parsed = %#v, want roleType witness", parsed)
|
||||
}
|
||||
if cfg == nil || cfg.StartCommand != "exec echo hq" {
|
||||
t.Fatalf("cfg.StartCommand = %#v, want %q", cfg, "exec echo hq")
|
||||
if cfg == nil {
|
||||
t.Fatal("cfg is nil")
|
||||
}
|
||||
// Should have the overridden start_command
|
||||
if cfg.StartCommand != "exec echo custom-town-command" {
|
||||
t.Errorf("cfg.StartCommand = %q, want %q", cfg.StartCommand, "exec echo custom-town-command")
|
||||
}
|
||||
// Should still have built-in session pattern (not overridden)
|
||||
if cfg.SessionPattern != "gt-{rig}-witness" {
|
||||
t.Errorf("cfg.SessionPattern = %q, want %q", cfg.SessionPattern, "gt-{rig}-witness")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoleConfigForIdentity_FallsBackToLegacyRoleBead(t *testing.T) {
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
t.Skip("bd not installed")
|
||||
// TestGetRoleConfigForIdentity_RigOverride tests that rig-level TOML overrides
|
||||
// take precedence over town-level overrides.
|
||||
func TestGetRoleConfigForIdentity_RigOverride(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigPath := filepath.Join(townRoot, "myrig")
|
||||
|
||||
// Create town-level override
|
||||
townRolesDir := filepath.Join(townRoot, "roles")
|
||||
if err := os.MkdirAll(townRolesDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir town roles: %v", err)
|
||||
}
|
||||
townOverride := `
|
||||
role = "witness"
|
||||
scope = "rig"
|
||||
|
||||
[session]
|
||||
start_command = "exec echo town-command"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(townRolesDir, "witness.toml"), []byte(townOverride), 0644); err != nil {
|
||||
t.Fatalf("write town witness.toml: %v", err)
|
||||
}
|
||||
|
||||
townRoot := t.TempDir()
|
||||
runBd(t, townRoot, "init", "--quiet", "--prefix", "gt")
|
||||
// Create rig-level override (should take precedence)
|
||||
rigRolesDir := filepath.Join(rigPath, "roles")
|
||||
if err := os.MkdirAll(rigRolesDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig roles: %v", err)
|
||||
}
|
||||
rigOverride := `
|
||||
role = "witness"
|
||||
scope = "rig"
|
||||
|
||||
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event")
|
||||
|
||||
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event")
|
||||
|
||||
// Only legacy role bead exists.
|
||||
runBd(t, townRoot, "create",
|
||||
"--id", "gt-witness-role",
|
||||
"--type", "role",
|
||||
"--title", "Witness Role (legacy)",
|
||||
"--description", "start_command: exec echo gt\n",
|
||||
)
|
||||
[session]
|
||||
start_command = "exec echo rig-command"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(rigRolesDir, "witness.toml"), []byte(rigOverride), 0644); err != nil {
|
||||
t.Fatalf("write rig witness.toml: %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
config: &Config{TownRoot: townRoot},
|
||||
@@ -90,7 +135,11 @@ func TestGetRoleConfigForIdentity_FallsBackToLegacyRoleBead(t *testing.T) {
|
||||
if parsed == nil || parsed.RoleType != "witness" {
|
||||
t.Fatalf("parsed = %#v, want roleType witness", parsed)
|
||||
}
|
||||
if cfg == nil || cfg.StartCommand != "exec echo gt" {
|
||||
t.Fatalf("cfg.StartCommand = %#v, want %q", cfg, "exec echo gt")
|
||||
if cfg == nil {
|
||||
t.Fatal("cfg is nil")
|
||||
}
|
||||
// Should have the rig-level override (takes precedence over town)
|
||||
if cfg.StartCommand != "exec echo rig-command" {
|
||||
t.Errorf("cfg.StartCommand = %q, want %q", cfg.StartCommand, "exec echo rig-command")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user