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
@@ -44,8 +44,8 @@ type Issue struct {
|
||||
|
||||
// Agent bead slots (type=agent only)
|
||||
HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook
|
||||
RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared)
|
||||
AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck)
|
||||
// Note: role_bead field removed - role definitions are now config-based
|
||||
|
||||
// Counts from list output
|
||||
DependencyCount int `json:"dependency_count,omitempty"`
|
||||
|
||||
@@ -15,10 +15,11 @@ type AgentFields struct {
|
||||
Rig string // Rig name (empty for global agents like mayor/deacon)
|
||||
AgentState string // spawning, working, done, stuck
|
||||
HookBead string // Currently pinned work bead ID
|
||||
RoleBead string // Role definition bead ID (canonical location; may not exist yet)
|
||||
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
||||
ActiveMR string // Currently active merge request bead ID (for traceability)
|
||||
NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
|
||||
// Note: RoleBead field removed - role definitions are now config-based.
|
||||
// See internal/config/roles/*.toml and config-based-roles.md.
|
||||
}
|
||||
|
||||
// Notification level constants
|
||||
@@ -53,11 +54,7 @@ func FormatAgentDescription(title string, fields *AgentFields) string {
|
||||
lines = append(lines, "hook_bead: null")
|
||||
}
|
||||
|
||||
if fields.RoleBead != "" {
|
||||
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
|
||||
} else {
|
||||
lines = append(lines, "role_bead: null")
|
||||
}
|
||||
// Note: role_bead field no longer written - role definitions are config-based
|
||||
|
||||
if fields.CleanupStatus != "" {
|
||||
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
|
||||
@@ -111,7 +108,7 @@ func ParseAgentFields(description string) *AgentFields {
|
||||
case "hook_bead":
|
||||
fields.HookBead = value
|
||||
case "role_bead":
|
||||
fields.RoleBead = value
|
||||
// Ignored - role definitions are now config-based (backward compat)
|
||||
case "cleanup_status":
|
||||
fields.CleanupStatus = value
|
||||
case "active_mr":
|
||||
@@ -158,13 +155,7 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
|
||||
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||
}
|
||||
|
||||
// Set the role slot if specified (this is the authoritative storage)
|
||||
if fields != nil && fields.RoleBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Note: role slot no longer set - role definitions are config-based
|
||||
|
||||
// Set the hook slot if specified (this is the authoritative storage)
|
||||
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
||||
@@ -223,13 +214,7 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
||||
return nil, fmt.Errorf("updating reopened agent bead: %w", err)
|
||||
}
|
||||
|
||||
// Set the role slot if specified
|
||||
if fields != nil && fields.RoleBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Note: role slot no longer set - role definitions are config-based
|
||||
|
||||
// Clear any existing hook slot (handles stale state from previous lifecycle)
|
||||
_, _ = b.run("slot", "clear", id, "hook")
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
// Package beads provides role bead management.
|
||||
//
|
||||
// DEPRECATED: Role beads are deprecated. Role definitions are now config-based.
|
||||
// See internal/config/roles/*.toml and config-based-roles.md for the new system.
|
||||
//
|
||||
// This file is kept for backward compatibility with existing role beads but
|
||||
// new code should use config.LoadRoleDefinition() instead of reading role beads.
|
||||
// The daemon no longer uses role beads as of Phase 2 (config-based roles).
|
||||
package beads
|
||||
|
||||
import (
|
||||
@@ -6,10 +13,12 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Role bead ID naming convention:
|
||||
// Role beads are stored in town beads (~/.beads/) with hq- prefix.
|
||||
// DEPRECATED: Role bead ID naming convention is no longer used.
|
||||
// Role definitions are now config-based (internal/config/roles/*.toml).
|
||||
//
|
||||
// Canonical format: hq-<role>-role
|
||||
// Role beads were stored in town beads (~/.beads/) with hq- prefix.
|
||||
//
|
||||
// Canonical format was: hq-<role>-role
|
||||
//
|
||||
// Examples:
|
||||
// - hq-mayor-role
|
||||
@@ -19,8 +28,8 @@ import (
|
||||
// - hq-crew-role
|
||||
// - hq-polecat-role
|
||||
//
|
||||
// Use RoleBeadIDTown() to get canonical role bead IDs.
|
||||
// The legacy RoleBeadID() function returns gt-<role>-role for backward compatibility.
|
||||
// Legacy functions RoleBeadID() and RoleBeadIDTown() still work for
|
||||
// backward compatibility but should not be used in new code.
|
||||
|
||||
// RoleBeadID returns the role bead ID for a given role type.
|
||||
// Role beads define lifecycle configuration for each agent type.
|
||||
@@ -67,6 +76,9 @@ func PolecatRoleBeadID() string {
|
||||
|
||||
// GetRoleConfig looks up a role bead and returns its parsed RoleConfig.
|
||||
// Returns nil, nil if the role bead doesn't exist or has no config.
|
||||
//
|
||||
// Deprecated: Use config.LoadRoleDefinition() instead. Role definitions
|
||||
// are now config-based, not stored as beads.
|
||||
func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) {
|
||||
issue, err := b.Show(roleBeadID)
|
||||
if err != nil {
|
||||
@@ -94,7 +106,9 @@ func HasLabel(issue *Issue, label string) bool {
|
||||
}
|
||||
|
||||
// RoleBeadDef defines a role bead's metadata.
|
||||
// Used by gt install and gt doctor to create missing role beads.
|
||||
//
|
||||
// Deprecated: Role beads are no longer created. Role definitions are
|
||||
// now config-based (internal/config/roles/*.toml).
|
||||
type RoleBeadDef struct {
|
||||
ID string // e.g., "hq-witness-role"
|
||||
Title string // e.g., "Witness Role"
|
||||
@@ -102,8 +116,9 @@ type RoleBeadDef struct {
|
||||
}
|
||||
|
||||
// AllRoleBeadDefs returns all role bead definitions.
|
||||
// This is the single source of truth for role beads used by both
|
||||
// gt install (initial creation) and gt doctor --fix (repair).
|
||||
//
|
||||
// Deprecated: Role beads are no longer created by gt install or gt doctor.
|
||||
// This function is kept for backward compatibility only.
|
||||
func AllRoleBeadDefs() []RoleBeadDef {
|
||||
return []RoleBeadDef{
|
||||
{
|
||||
|
||||
@@ -1972,7 +1972,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-task-1",
|
||||
RoleBead: "test-polecat-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn 1 - CreateOrReopenAgentBead: %v", err)
|
||||
@@ -1993,7 +1992,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-task-2", // Different task
|
||||
RoleBead: "test-polecat-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn 2 - CreateOrReopenAgentBead: %v", err)
|
||||
@@ -2020,7 +2018,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-task-3",
|
||||
RoleBead: "test-polecat-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn 3 - CreateOrReopenAgentBead: %v", err)
|
||||
@@ -2059,7 +2056,6 @@ func TestCloseAndClearAgentBead_FieldClearing(t *testing.T) {
|
||||
Rig: "testrig",
|
||||
AgentState: "running",
|
||||
HookBead: "test-issue-123",
|
||||
RoleBead: "test-polecat-role",
|
||||
CleanupStatus: "clean",
|
||||
ActiveMR: "test-mr-456",
|
||||
NotificationLevel: "normal",
|
||||
@@ -2279,7 +2275,6 @@ func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) {
|
||||
Rig: "testrig",
|
||||
AgentState: "running",
|
||||
HookBead: "test-old-issue",
|
||||
RoleBead: "test-polecat-role",
|
||||
CleanupStatus: "clean",
|
||||
ActiveMR: "test-old-mr",
|
||||
NotificationLevel: "normal",
|
||||
@@ -2300,7 +2295,6 @@ func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) {
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-new-issue",
|
||||
RoleBead: "test-polecat-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrReopenAgentBead: %v", err)
|
||||
|
||||
@@ -106,7 +106,6 @@ func runCrewAdd(cmd *cobra.Command, args []string) error {
|
||||
RoleType: "crew",
|
||||
Rig: rigName,
|
||||
AgentState: "idle",
|
||||
RoleBead: beads.RoleBeadIDTown("crew"),
|
||||
}
|
||||
desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", name, rigName)
|
||||
if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil {
|
||||
|
||||
@@ -248,7 +248,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Create town-level agent beads (Mayor, Deacon) and role beads.
|
||||
// Create town-level agent beads (Mayor, Deacon).
|
||||
// These use hq- prefix and are stored in town beads for cross-rig coordination.
|
||||
if err := initTownAgentBeads(absPath); err != nil {
|
||||
fmt.Printf(" %s Could not create town-level agent beads: %v\n", style.Dim.Render("⚠"), err)
|
||||
@@ -448,58 +448,30 @@ func ensureCustomTypes(beadsPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// initTownAgentBeads creates town-level agent and role beads using hq- prefix.
|
||||
// initTownAgentBeads creates town-level agent beads using hq- prefix.
|
||||
// This creates:
|
||||
// - hq-mayor, hq-deacon (agent beads for town-level agents)
|
||||
// - hq-mayor-role, hq-deacon-role, hq-witness-role, hq-refinery-role,
|
||||
// hq-polecat-role, hq-crew-role (role definition beads)
|
||||
//
|
||||
// These beads are stored in town beads (~/gt/.beads/) and are shared across all rigs.
|
||||
// Rig-level agent beads (witness, refinery) are created by gt rig add in rig beads.
|
||||
//
|
||||
// ERROR HANDLING ASYMMETRY:
|
||||
// Agent beads (Mayor, Deacon) use hard fail - installation aborts if creation fails.
|
||||
// Role beads use soft fail - logs warning and continues if creation fails.
|
||||
// Note: Role definitions are now config-based (internal/config/roles/*.toml),
|
||||
// not stored as beads. See config-based-roles.md for details.
|
||||
//
|
||||
// Rationale: Agent beads are identity beads that track agent state, hooks, and
|
||||
// Agent beads use hard fail - installation aborts if creation fails.
|
||||
// Agent beads are identity beads that track agent state, hooks, and
|
||||
// form the foundation of the CV/reputation ledger. Without them, agents cannot
|
||||
// be properly tracked or coordinated. Role beads are documentation templates
|
||||
// that define role characteristics but are not required for agent operation -
|
||||
// agents can function without their role bead existing.
|
||||
// be properly tracked or coordinated.
|
||||
func initTownAgentBeads(townPath string) error {
|
||||
bd := beads.New(townPath)
|
||||
|
||||
// bd init doesn't enable "custom" issue types by default, but Gas Town uses
|
||||
// agent/role beads during install and runtime. Ensure these types are enabled
|
||||
// agent beads during install and runtime. Ensure these types are enabled
|
||||
// before attempting to create any town-level system beads.
|
||||
if err := ensureBeadsCustomTypes(townPath, []string{"agent", "role", "rig", "convoy", "slot"}); err != nil {
|
||||
if err := ensureBeadsCustomTypes(townPath, []string{"agent", "rig", "convoy", "slot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Role beads (global templates) - use shared definitions from beads package
|
||||
for _, role := range beads.AllRoleBeadDefs() {
|
||||
// Check if already exists
|
||||
if _, err := bd.Show(role.ID); err == nil {
|
||||
continue // Already exists
|
||||
}
|
||||
|
||||
// Create role bead using the beads API
|
||||
// CreateWithID with Type: "role" automatically adds gt:role label
|
||||
_, err := bd.CreateWithID(role.ID, beads.CreateOptions{
|
||||
Title: role.Title,
|
||||
Type: "role",
|
||||
Description: role.Desc,
|
||||
Priority: -1, // No priority
|
||||
})
|
||||
if err != nil {
|
||||
// Log but continue - role beads are optional
|
||||
fmt.Printf(" %s Could not create role bead %s: %v\n",
|
||||
style.Dim.Render("⚠"), role.ID, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" ✓ Created role bead: %s\n", role.ID)
|
||||
}
|
||||
|
||||
// Town-level agent beads
|
||||
agentDefs := []struct {
|
||||
id string
|
||||
@@ -541,7 +513,7 @@ func initTownAgentBeads(townPath string) error {
|
||||
Rig: "", // Town-level agents have no rig
|
||||
AgentState: "idle",
|
||||
HookBead: "",
|
||||
RoleBead: beads.RoleBeadIDTown(agent.roleType),
|
||||
// Note: RoleBead field removed - role definitions are now config-based
|
||||
}
|
||||
|
||||
if _, err := bd.CreateAgentBead(agent.id, agent.title, fields); err != nil {
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
var (
|
||||
migrateAgentsDryRun bool
|
||||
migrateAgentsForce bool
|
||||
)
|
||||
|
||||
var migrateAgentsCmd = &cobra.Command{
|
||||
Use: "migrate-agents",
|
||||
GroupID: GroupDiag,
|
||||
Short: "Migrate agent beads to two-level architecture",
|
||||
Long: `Migrate agent beads from the old single-tier to the two-level architecture.
|
||||
|
||||
This command migrates town-level agent beads (Mayor, Deacon) from rig beads
|
||||
with gt-* prefix to town beads with hq-* prefix:
|
||||
|
||||
OLD (rig beads): gt-mayor, gt-deacon
|
||||
NEW (town beads): hq-mayor, hq-deacon
|
||||
|
||||
Rig-level agents (Witness, Refinery, Polecats) remain in rig beads unchanged.
|
||||
|
||||
The migration:
|
||||
1. Detects old gt-mayor/gt-deacon beads in rig beads
|
||||
2. Creates new hq-mayor/hq-deacon beads in town beads
|
||||
3. Copies agent state (hook_bead, agent_state, etc.)
|
||||
4. Adds migration note to old beads (preserves them)
|
||||
|
||||
Safety:
|
||||
- Dry-run mode by default (use --execute to apply changes)
|
||||
- Old beads are preserved with migration notes
|
||||
- Validates new beads exist before marking migration complete
|
||||
- Skips if new beads already exist (idempotent)
|
||||
|
||||
Examples:
|
||||
gt migrate-agents # Dry-run: show what would be migrated
|
||||
gt migrate-agents --execute # Apply the migration
|
||||
gt migrate-agents --force # Re-migrate even if new beads exist`,
|
||||
RunE: runMigrateAgents,
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrateAgentsCmd.Flags().BoolVar(&migrateAgentsDryRun, "dry-run", true, "Show what would be migrated without making changes (default)")
|
||||
migrateAgentsCmd.Flags().BoolVar(&migrateAgentsForce, "force", false, "Re-migrate even if new beads already exist")
|
||||
// Add --execute as inverse of --dry-run for clarity
|
||||
migrateAgentsCmd.Flags().BoolP("execute", "x", false, "Actually apply the migration (opposite of --dry-run)")
|
||||
rootCmd.AddCommand(migrateAgentsCmd)
|
||||
}
|
||||
|
||||
// migrationResult holds the result of a single bead migration.
|
||||
type migrationResult struct {
|
||||
OldID string
|
||||
NewID string
|
||||
Status string // "migrated", "skipped", "error"
|
||||
Message string
|
||||
OldFields *beads.AgentFields
|
||||
WasDryRun bool
|
||||
}
|
||||
|
||||
func runMigrateAgents(cmd *cobra.Command, args []string) error {
|
||||
// Handle --execute flag
|
||||
if execute, _ := cmd.Flags().GetBool("execute"); execute {
|
||||
migrateAgentsDryRun = false
|
||||
}
|
||||
|
||||
// Find town root
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get town beads path
|
||||
townBeadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// Load routes to find rig beads
|
||||
routes, err := beads.LoadRoutes(townBeadsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading routes.jsonl: %w", err)
|
||||
}
|
||||
|
||||
// Find the first rig with gt- prefix (where global agents are currently stored)
|
||||
var sourceRigPath string
|
||||
for _, r := range routes {
|
||||
if strings.TrimSuffix(r.Prefix, "-") == "gt" && r.Path != "." {
|
||||
sourceRigPath = r.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sourceRigPath == "" {
|
||||
fmt.Println("No rig with gt- prefix found. Nothing to migrate.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Source beads (rig beads where old agent beads are)
|
||||
sourceBeadsDir := filepath.Join(townRoot, sourceRigPath, ".beads")
|
||||
sourceBd := beads.New(sourceBeadsDir)
|
||||
|
||||
// Target beads (town beads where new agent beads should go)
|
||||
targetBd := beads.NewWithBeadsDir(townRoot, townBeadsDir)
|
||||
|
||||
// Agents to migrate: town-level agents only
|
||||
agentsToMigrate := []struct {
|
||||
oldID string
|
||||
newID string
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
oldID: beads.MayorBeadID(), // gt-mayor
|
||||
newID: beads.MayorBeadIDTown(), // hq-mayor
|
||||
desc: "Mayor - global coordinator, handles cross-rig communication and escalations.",
|
||||
},
|
||||
{
|
||||
oldID: beads.DeaconBeadID(), // gt-deacon
|
||||
newID: beads.DeaconBeadIDTown(), // hq-deacon
|
||||
desc: "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring.",
|
||||
},
|
||||
}
|
||||
|
||||
// Also migrate role beads
|
||||
rolesToMigrate := []string{"mayor", "deacon", "witness", "refinery", "polecat", "crew", "dog"}
|
||||
|
||||
if migrateAgentsDryRun {
|
||||
fmt.Println("🔍 DRY RUN: Showing what would be migrated")
|
||||
fmt.Println(" Use --execute to apply changes")
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Println("🚀 Migrating agent beads to two-level architecture")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
var results []migrationResult
|
||||
|
||||
// Migrate agent beads
|
||||
fmt.Println("Agent Beads:")
|
||||
for _, agent := range agentsToMigrate {
|
||||
result := migrateAgentBead(sourceBd, targetBd, agent.oldID, agent.newID, agent.desc, migrateAgentsDryRun, migrateAgentsForce)
|
||||
results = append(results, result)
|
||||
printMigrationResult(result)
|
||||
}
|
||||
|
||||
// Migrate role beads
|
||||
fmt.Println("\nRole Beads:")
|
||||
for _, role := range rolesToMigrate {
|
||||
oldID := "gt-" + role + "-role"
|
||||
newID := beads.RoleBeadIDTown(role) // hq-<role>-role
|
||||
result := migrateRoleBead(sourceBd, targetBd, oldID, newID, role, migrateAgentsDryRun, migrateAgentsForce)
|
||||
results = append(results, result)
|
||||
printMigrationResult(result)
|
||||
}
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
printMigrationSummary(results, migrateAgentsDryRun)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateAgentBead migrates a single agent bead from source to target.
|
||||
func migrateAgentBead(sourceBd, targetBd *beads.Beads, oldID, newID, desc string, dryRun, force bool) migrationResult {
|
||||
result := migrationResult{
|
||||
OldID: oldID,
|
||||
NewID: newID,
|
||||
WasDryRun: dryRun,
|
||||
}
|
||||
|
||||
// Check if old bead exists
|
||||
oldIssue, oldFields, err := sourceBd.GetAgentBead(oldID)
|
||||
if err != nil {
|
||||
result.Status = "skipped"
|
||||
result.Message = "old bead not found"
|
||||
return result
|
||||
}
|
||||
result.OldFields = oldFields
|
||||
|
||||
// Check if new bead already exists
|
||||
if _, err := targetBd.Show(newID); err == nil {
|
||||
if !force {
|
||||
result.Status = "skipped"
|
||||
result.Message = "new bead already exists (use --force to re-migrate)"
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
result.Status = "would migrate"
|
||||
result.Message = fmt.Sprintf("would copy state from %s", oldIssue.ID)
|
||||
return result
|
||||
}
|
||||
|
||||
// Create new bead in town beads
|
||||
newFields := &beads.AgentFields{
|
||||
RoleType: oldFields.RoleType,
|
||||
Rig: oldFields.Rig,
|
||||
AgentState: oldFields.AgentState,
|
||||
HookBead: oldFields.HookBead,
|
||||
RoleBead: beads.RoleBeadIDTown(oldFields.RoleType), // Update to hq- role
|
||||
CleanupStatus: oldFields.CleanupStatus,
|
||||
ActiveMR: oldFields.ActiveMR,
|
||||
NotificationLevel: oldFields.NotificationLevel,
|
||||
}
|
||||
|
||||
_, err = targetBd.CreateAgentBead(newID, desc, newFields)
|
||||
if err != nil {
|
||||
result.Status = "error"
|
||||
result.Message = fmt.Sprintf("failed to create: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Add migration label to old bead
|
||||
migrationLabel := fmt.Sprintf("migrated-to:%s", newID)
|
||||
if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil {
|
||||
// Non-fatal: just log it
|
||||
result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err)
|
||||
}
|
||||
|
||||
result.Status = "migrated"
|
||||
result.Message = "successfully migrated"
|
||||
return result
|
||||
}
|
||||
|
||||
// migrateRoleBead migrates a role definition bead.
|
||||
func migrateRoleBead(sourceBd, targetBd *beads.Beads, oldID, newID, role string, dryRun, force bool) migrationResult {
|
||||
result := migrationResult{
|
||||
OldID: oldID,
|
||||
NewID: newID,
|
||||
WasDryRun: dryRun,
|
||||
}
|
||||
|
||||
// Check if old bead exists
|
||||
oldIssue, err := sourceBd.Show(oldID)
|
||||
if err != nil {
|
||||
result.Status = "skipped"
|
||||
result.Message = "old bead not found"
|
||||
return result
|
||||
}
|
||||
|
||||
// Check if new bead already exists
|
||||
if _, err := targetBd.Show(newID); err == nil {
|
||||
if !force {
|
||||
result.Status = "skipped"
|
||||
result.Message = "new bead already exists (use --force to re-migrate)"
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
result.Status = "would migrate"
|
||||
result.Message = fmt.Sprintf("would copy from %s", oldIssue.ID)
|
||||
return result
|
||||
}
|
||||
|
||||
// Create new role bead in town beads
|
||||
// Role beads are simple - just copy the description
|
||||
_, err = targetBd.CreateWithID(newID, beads.CreateOptions{
|
||||
Title: fmt.Sprintf("Role: %s", role),
|
||||
Type: "role",
|
||||
Description: oldIssue.Title, // Use old title as description
|
||||
})
|
||||
if err != nil {
|
||||
result.Status = "error"
|
||||
result.Message = fmt.Sprintf("failed to create: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Add migration label to old bead
|
||||
migrationLabel := fmt.Sprintf("migrated-to:%s", newID)
|
||||
if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil {
|
||||
// Non-fatal
|
||||
result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err)
|
||||
}
|
||||
|
||||
result.Status = "migrated"
|
||||
result.Message = "successfully migrated"
|
||||
return result
|
||||
}
|
||||
|
||||
func getMigrationStatusIcon(status string) string {
|
||||
switch status {
|
||||
case "migrated", "would migrate":
|
||||
return " ✓"
|
||||
case "skipped":
|
||||
return " ⊘"
|
||||
case "error":
|
||||
return " ✗"
|
||||
default:
|
||||
return " ?"
|
||||
}
|
||||
}
|
||||
|
||||
func printMigrationResult(r migrationResult) {
|
||||
fmt.Printf("%s %s → %s: %s\n", getMigrationStatusIcon(r.Status), r.OldID, r.NewID, r.Message)
|
||||
}
|
||||
|
||||
func printMigrationSummary(results []migrationResult, dryRun bool) {
|
||||
var migrated, skipped, errors int
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case "migrated", "would migrate":
|
||||
migrated++
|
||||
case "skipped":
|
||||
skipped++
|
||||
case "error":
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("Summary (dry-run): %d would migrate, %d skipped, %d errors\n", migrated, skipped, errors)
|
||||
if migrated > 0 {
|
||||
fmt.Println("\nRun with --execute to apply these changes.")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Summary: %d migrated, %d skipped, %d errors\n", migrated, skipped, errors)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
)
|
||||
|
||||
func TestMigrationResultStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result migrationResult
|
||||
wantIcon string
|
||||
}{
|
||||
{
|
||||
name: "migrated shows checkmark",
|
||||
result: migrationResult{
|
||||
OldID: "gt-mayor",
|
||||
NewID: "hq-mayor",
|
||||
Status: "migrated",
|
||||
Message: "successfully migrated",
|
||||
},
|
||||
wantIcon: " ✓",
|
||||
},
|
||||
{
|
||||
name: "would migrate shows checkmark",
|
||||
result: migrationResult{
|
||||
OldID: "gt-mayor",
|
||||
NewID: "hq-mayor",
|
||||
Status: "would migrate",
|
||||
Message: "would copy state from gt-mayor",
|
||||
},
|
||||
wantIcon: " ✓",
|
||||
},
|
||||
{
|
||||
name: "skipped shows empty circle",
|
||||
result: migrationResult{
|
||||
OldID: "gt-mayor",
|
||||
NewID: "hq-mayor",
|
||||
Status: "skipped",
|
||||
Message: "already exists",
|
||||
},
|
||||
wantIcon: " ⊘",
|
||||
},
|
||||
{
|
||||
name: "error shows X",
|
||||
result: migrationResult{
|
||||
OldID: "gt-mayor",
|
||||
NewID: "hq-mayor",
|
||||
Status: "error",
|
||||
Message: "failed to create",
|
||||
},
|
||||
wantIcon: " ✗",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
icon := getMigrationStatusIcon(tt.result.Status)
|
||||
if icon != tt.wantIcon {
|
||||
t.Errorf("getMigrationStatusIcon(%q) = %q, want %q", tt.result.Status, icon, tt.wantIcon)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTownBeadIDHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{"MayorBeadIDTown", beads.MayorBeadIDTown(), "hq-mayor"},
|
||||
{"DeaconBeadIDTown", beads.DeaconBeadIDTown(), "hq-deacon"},
|
||||
{"DogBeadIDTown", beads.DogBeadIDTown("fido"), "hq-dog-fido"},
|
||||
{"RoleBeadIDTown mayor", beads.RoleBeadIDTown("mayor"), "hq-mayor-role"},
|
||||
{"RoleBeadIDTown witness", beads.RoleBeadIDTown("witness"), "hq-witness-role"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
|
||||
RoleType: "deacon",
|
||||
Rig: "",
|
||||
AgentState: "idle",
|
||||
RoleBead: beads.DeaconRoleBeadIDTown(),
|
||||
}
|
||||
desc := "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring."
|
||||
if _, err := townBd.CreateAgentBead(deaconID, desc, fields); err != nil {
|
||||
@@ -184,7 +183,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
|
||||
RoleType: "mayor",
|
||||
Rig: "",
|
||||
AgentState: "idle",
|
||||
RoleBead: beads.MayorRoleBeadIDTown(),
|
||||
}
|
||||
desc := "Mayor - global coordinator, handles cross-rig communication and escalations."
|
||||
if _, err := townBd.CreateAgentBead(mayorID, desc, fields); err != nil {
|
||||
@@ -231,7 +229,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
|
||||
RoleType: "witness",
|
||||
Rig: rigName,
|
||||
AgentState: "idle",
|
||||
RoleBead: beads.RoleBeadIDTown("witness"),
|
||||
}
|
||||
desc := fmt.Sprintf("Witness for %s - monitors polecat health and progress.", rigName)
|
||||
if _, err := bd.CreateAgentBead(witnessID, desc, fields); err != nil {
|
||||
@@ -245,7 +242,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
|
||||
RoleType: "refinery",
|
||||
Rig: rigName,
|
||||
AgentState: "idle",
|
||||
RoleBead: beads.RoleBeadIDTown("refinery"),
|
||||
}
|
||||
desc := fmt.Sprintf("Refinery for %s - processes merge queue.", rigName)
|
||||
if _, err := bd.CreateAgentBead(refineryID, desc, fields); err != nil {
|
||||
@@ -262,7 +258,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
|
||||
RoleType: "crew",
|
||||
Rig: rigName,
|
||||
AgentState: "idle",
|
||||
RoleBead: beads.RoleBeadIDTown("crew"),
|
||||
}
|
||||
desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", workerName, rigName)
|
||||
if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil {
|
||||
|
||||
@@ -2,119 +2,116 @@ package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
// RoleBeadsCheck verifies that role definition beads exist.
|
||||
// Role beads are templates that define role characteristics and lifecycle hooks.
|
||||
// They are stored in town beads (~/.beads/) with hq- prefix:
|
||||
// - hq-mayor-role, hq-deacon-role, hq-dog-role
|
||||
// - hq-witness-role, hq-refinery-role, hq-polecat-role, hq-crew-role
|
||||
//
|
||||
// Role beads are created by gt install, but creation may fail silently.
|
||||
// Without role beads, agents fall back to defaults which may differ from
|
||||
// user expectations.
|
||||
type RoleBeadsCheck struct {
|
||||
FixableCheck
|
||||
missing []string // Track missing role beads for fix
|
||||
// RoleConfigCheck verifies that role configuration is valid.
|
||||
// Role definitions are now config-based (internal/config/roles/*.toml),
|
||||
// not stored as beads. Built-in defaults are embedded in the binary.
|
||||
// This check validates any user-provided overrides at:
|
||||
// - <town>/roles/<role>.toml (town-level overrides)
|
||||
// - <rig>/roles/<role>.toml (rig-level overrides)
|
||||
type RoleConfigCheck struct {
|
||||
BaseCheck
|
||||
}
|
||||
|
||||
// NewRoleBeadsCheck creates a new role beads check.
|
||||
func NewRoleBeadsCheck() *RoleBeadsCheck {
|
||||
return &RoleBeadsCheck{
|
||||
FixableCheck: FixableCheck{
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "role-beads-exist",
|
||||
CheckDescription: "Verify role definition beads exist",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
// NewRoleBeadsCheck creates a new role config check.
|
||||
// Note: Function name kept as NewRoleBeadsCheck for backward compatibility
|
||||
// with existing doctor.go registration code.
|
||||
func NewRoleBeadsCheck() *RoleConfigCheck {
|
||||
return &RoleConfigCheck{
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "role-config-valid",
|
||||
CheckDescription: "Verify role configuration is valid",
|
||||
CheckCategory: CategoryConfig,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks if role beads exist.
|
||||
func (c *RoleBeadsCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
c.missing = nil // Reset
|
||||
// Run checks if role config is valid.
|
||||
func (c *RoleConfigCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
var warnings []string
|
||||
var overrideCount int
|
||||
|
||||
townBeadsPath := beads.GetTownBeadsPath(ctx.TownRoot)
|
||||
bd := beads.New(townBeadsPath)
|
||||
|
||||
var missing []string
|
||||
roleDefs := beads.AllRoleBeadDefs()
|
||||
|
||||
for _, role := range roleDefs {
|
||||
if _, err := bd.Show(role.ID); err != nil {
|
||||
missing = append(missing, role.ID)
|
||||
// Check town-level overrides
|
||||
townRolesDir := filepath.Join(ctx.TownRoot, "roles")
|
||||
if entries, err := os.ReadDir(townRolesDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".toml" {
|
||||
overrideCount++
|
||||
path := filepath.Join(townRolesDir, entry.Name())
|
||||
if err := validateRoleOverride(path); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("town override %s: %v", entry.Name(), err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.missing = missing
|
||||
// Check rig-level overrides for each rig
|
||||
// Discover rigs by looking for directories with rig.json
|
||||
if entries, err := os.ReadDir(ctx.TownRoot); err == nil {
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
rigName := entry.Name()
|
||||
// Check if this is a rig (has rig.json)
|
||||
if _, err := os.Stat(filepath.Join(ctx.TownRoot, rigName, "rig.json")); err != nil {
|
||||
continue
|
||||
}
|
||||
rigRolesDir := filepath.Join(ctx.TownRoot, rigName, "roles")
|
||||
if roleEntries, err := os.ReadDir(rigRolesDir); err == nil {
|
||||
for _, roleEntry := range roleEntries {
|
||||
if !roleEntry.IsDir() && filepath.Ext(roleEntry.Name()) == ".toml" {
|
||||
overrideCount++
|
||||
path := filepath.Join(rigRolesDir, roleEntry.Name())
|
||||
if err := validateRoleOverride(path); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("rig %s override %s: %v", rigName, roleEntry.Name(), err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
if len(warnings) > 0 {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("All %d role beads exist", len(roleDefs)),
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("%d role config override(s) have issues", len(warnings)),
|
||||
Details: warnings,
|
||||
FixHint: "Check TOML syntax in role override files",
|
||||
Category: c.Category(),
|
||||
}
|
||||
}
|
||||
|
||||
msg := "Role config uses built-in defaults"
|
||||
if overrideCount > 0 {
|
||||
msg = fmt.Sprintf("Role config valid (%d override file(s))", overrideCount)
|
||||
}
|
||||
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusWarning, // Warning, not error - agents work without role beads
|
||||
Message: fmt.Sprintf("%d role bead(s) missing (agents will use defaults)", len(missing)),
|
||||
Details: missing,
|
||||
FixHint: "Run 'gt doctor --fix' to create missing role beads",
|
||||
Status: StatusOK,
|
||||
Message: msg,
|
||||
Category: c.Category(),
|
||||
}
|
||||
}
|
||||
|
||||
// Fix creates missing role beads.
|
||||
func (c *RoleBeadsCheck) Fix(ctx *CheckContext) error {
|
||||
// Re-run check to populate missing if needed
|
||||
if c.missing == nil {
|
||||
result := c.Run(ctx)
|
||||
if result.Status == StatusOK {
|
||||
return nil // Nothing to fix
|
||||
}
|
||||
// validateRoleOverride checks if a role override file is valid TOML.
|
||||
func validateRoleOverride(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(c.missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build lookup map for role definitions
|
||||
roleDefMap := make(map[string]beads.RoleBeadDef)
|
||||
for _, role := range beads.AllRoleBeadDefs() {
|
||||
roleDefMap[role.ID] = role
|
||||
}
|
||||
|
||||
// Create missing role beads
|
||||
for _, id := range c.missing {
|
||||
role, ok := roleDefMap[id]
|
||||
if !ok {
|
||||
continue // Shouldn't happen
|
||||
}
|
||||
|
||||
// Create role bead using bd create --type=role
|
||||
args := []string{
|
||||
"create",
|
||||
"--type=role",
|
||||
"--id=" + role.ID,
|
||||
"--title=" + role.Title,
|
||||
"--description=" + role.Desc,
|
||||
}
|
||||
if beads.NeedsForceForID(role.ID) {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Dir = ctx.TownRoot
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("creating %s: %s", role.ID, strings.TrimSpace(string(output)))
|
||||
}
|
||||
var def config.RoleDefinition
|
||||
if err := toml.Unmarshal(data, &def); err != nil {
|
||||
return fmt.Errorf("invalid TOML: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -4,15 +4,64 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
)
|
||||
|
||||
func TestRoleBeadsCheck_Run(t *testing.T) {
|
||||
t.Run("no town beads returns warning", func(t *testing.T) {
|
||||
func TestRoleConfigCheck_Run(t *testing.T) {
|
||||
t.Run("no overrides returns OK with defaults message", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// Create minimal town structure without .beads
|
||||
if err := os.MkdirAll(filepath.Join(tmpDir, "mayor"), 0755); err != nil {
|
||||
|
||||
check := NewRoleBeadsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
if result.Message != "Role config uses built-in defaults" {
|
||||
t.Errorf("unexpected message: %s", result.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid town override returns OK", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rolesDir := filepath.Join(tmpDir, "roles")
|
||||
if err := os.MkdirAll(rolesDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a valid TOML override
|
||||
override := `
|
||||
role = "witness"
|
||||
scope = "rig"
|
||||
|
||||
[session]
|
||||
start_command = "exec echo test"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte(override), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewRoleBeadsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
if result.Message != "Role config valid (1 override file(s))" {
|
||||
t.Errorf("unexpected message: %s", result.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid town override returns warning", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rolesDir := filepath.Join(tmpDir, "roles")
|
||||
if err := os.MkdirAll(rolesDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create an invalid TOML file
|
||||
if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte("invalid { toml"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -20,49 +69,53 @@ func TestRoleBeadsCheck_Run(t *testing.T) {
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
result := check.Run(ctx)
|
||||
|
||||
// Without .beads directory, all role beads are "missing"
|
||||
expectedCount := len(beads.AllRoleBeadDefs())
|
||||
if result.Status != StatusWarning {
|
||||
t.Errorf("expected StatusWarning, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
if len(result.Details) != expectedCount {
|
||||
t.Errorf("expected %d missing role beads, got %d: %v", expectedCount, len(result.Details), result.Details)
|
||||
if len(result.Details) != 1 {
|
||||
t.Errorf("expected 1 warning detail, got %d", len(result.Details))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("check is fixable", func(t *testing.T) {
|
||||
t.Run("valid rig override returns OK", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
rigRolesDir := filepath.Join(rigDir, "roles")
|
||||
if err := os.MkdirAll(rigRolesDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create rig.json to mark this as a rig
|
||||
if err := os.WriteFile(filepath.Join(rigDir, "rig.json"), []byte(`{"name": "testrig"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a valid TOML override
|
||||
override := `
|
||||
role = "refinery"
|
||||
scope = "rig"
|
||||
|
||||
[session]
|
||||
needs_pre_sync = true
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(rigRolesDir, "refinery.toml"), []byte(override), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewRoleBeadsCheck()
|
||||
if !check.CanFix() {
|
||||
t.Error("RoleBeadsCheck should be fixable")
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("check is not fixable", func(t *testing.T) {
|
||||
check := NewRoleBeadsCheck()
|
||||
if check.CanFix() {
|
||||
t.Error("RoleConfigCheck should not be fixable (config issues need manual fix)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoleBeadsCheck_usesSharedDefs(t *testing.T) {
|
||||
// Verify the check uses beads.AllRoleBeadDefs()
|
||||
roleDefs := beads.AllRoleBeadDefs()
|
||||
|
||||
if len(roleDefs) < 7 {
|
||||
t.Errorf("expected at least 7 role beads, got %d", len(roleDefs))
|
||||
}
|
||||
|
||||
// Verify key roles are present
|
||||
expectedIDs := map[string]bool{
|
||||
"hq-mayor-role": false,
|
||||
"hq-deacon-role": false,
|
||||
"hq-witness-role": false,
|
||||
"hq-refinery-role": false,
|
||||
}
|
||||
|
||||
for _, role := range roleDefs {
|
||||
if _, exists := expectedIDs[role.ID]; exists {
|
||||
expectedIDs[role.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for id, found := range expectedIDs {
|
||||
if !found {
|
||||
t.Errorf("expected role %s not found in AllRoleBeadDefs()", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +353,6 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
|
||||
RoleType: "polecat",
|
||||
Rig: m.rig.Name,
|
||||
AgentState: "spawning",
|
||||
RoleBead: beads.RoleBeadIDTown("polecat"),
|
||||
HookBead: opts.HookBead, // Set atomically at spawn time
|
||||
})
|
||||
if err != nil {
|
||||
@@ -648,7 +647,6 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
|
||||
RoleType: "polecat",
|
||||
Rig: m.rig.Name,
|
||||
AgentState: "spawning",
|
||||
RoleBead: beads.RoleBeadIDTown("polecat"),
|
||||
HookBead: opts.HookBead, // Set atomically at spawn time
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -750,14 +750,12 @@ func (m *Manager) initAgentBeads(rigPath, rigName, prefix string) error {
|
||||
continue // Already exists
|
||||
}
|
||||
|
||||
// RoleBead points to the shared role definition bead for this agent type.
|
||||
// Role beads are in town beads with hq- prefix (e.g., hq-witness-role).
|
||||
// Note: RoleBead field removed - role definitions are now config-based
|
||||
fields := &beads.AgentFields{
|
||||
RoleType: agent.roleType,
|
||||
Rig: agent.rig,
|
||||
AgentState: "idle",
|
||||
HookBead: "",
|
||||
RoleBead: beads.RoleBeadIDTown(agent.roleType),
|
||||
}
|
||||
|
||||
if _, err := bd.CreateAgentBead(agent.id, agent.desc, fields); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user