feat(migration): Add gt migrate-agents command for two-level architecture (gt-nnub1)
Implements Phase 4 of the two-level beads architecture migration: - Add hq- prefix helper functions for town-level agent beads - Add CreateWithID method for deterministic bead creation - Create gt migrate-agents command with dry-run/execute modes - Migrate gt-mayor/gt-deacon to hq-mayor/hq-deacon in town beads - Migrate role beads (gt-*-role) to town beads (hq-*-role) - Add migration labels to old beads for tracking - Idempotent: skips already-migrated beads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -512,6 +512,49 @@ func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
// CreateWithID creates an issue with a specific ID.
|
||||
// This is useful for agent beads, role beads, and other beads that need
|
||||
// deterministic IDs rather than auto-generated ones.
|
||||
func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) {
|
||||
args := []string{"create", "--json", "--id=" + id}
|
||||
|
||||
if opts.Title != "" {
|
||||
args = append(args, "--title="+opts.Title)
|
||||
}
|
||||
if opts.Type != "" {
|
||||
args = append(args, "--type="+opts.Type)
|
||||
}
|
||||
if opts.Priority >= 0 {
|
||||
args = append(args, fmt.Sprintf("--priority=%d", opts.Priority))
|
||||
}
|
||||
if opts.Description != "" {
|
||||
args = append(args, "--description="+opts.Description)
|
||||
}
|
||||
if opts.Parent != "" {
|
||||
args = append(args, "--parent="+opts.Parent)
|
||||
}
|
||||
// Default Actor from BD_ACTOR env var if not specified
|
||||
actor := opts.Actor
|
||||
if actor == "" {
|
||||
actor = os.Getenv("BD_ACTOR")
|
||||
}
|
||||
if actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
out, err := b.run(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var issue Issue
|
||||
if err := json.Unmarshal(out, &issue); err != nil {
|
||||
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||
}
|
||||
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
// Update updates an existing issue.
|
||||
func (b *Beads) Update(id string, opts UpdateOptions) error {
|
||||
args := []string{"update", id}
|
||||
@@ -1146,6 +1189,36 @@ func DogRoleBeadID() string {
|
||||
return RoleBeadID("dog")
|
||||
}
|
||||
|
||||
// Town-level agent bead ID helpers (hq- prefix, stored in town beads).
|
||||
// These are the canonical IDs for the two-level beads architecture:
|
||||
// - Town-level agents (Mayor, Deacon, Dogs) → ~/gt/.beads/ with hq- prefix
|
||||
// - Rig-level agents (Witness, Refinery, Polecats) → <rig>/.beads/ with rig prefix
|
||||
|
||||
// MayorBeadIDTown returns the town-level Mayor agent bead ID.
|
||||
// Deprecated: Use MayorBeadID() which still returns gt-mayor for compatibility.
|
||||
// After migration to two-level architecture, this will become the canonical ID.
|
||||
func MayorBeadIDTown() string {
|
||||
return "hq-mayor"
|
||||
}
|
||||
|
||||
// DeaconBeadIDTown returns the town-level Deacon agent bead ID.
|
||||
// Deprecated: Use DeaconBeadID() which still returns gt-deacon for compatibility.
|
||||
// After migration to two-level architecture, this will become the canonical ID.
|
||||
func DeaconBeadIDTown() string {
|
||||
return "hq-deacon"
|
||||
}
|
||||
|
||||
// DogBeadIDTown returns a town-level Dog agent bead ID.
|
||||
func DogBeadIDTown(name string) string {
|
||||
return "hq-dog-" + name
|
||||
}
|
||||
|
||||
// RoleBeadIDTown returns a town-level role definition bead ID.
|
||||
// Role beads are global templates stored in town beads.
|
||||
func RoleBeadIDTown(role string) string {
|
||||
return "hq-" + role + "-role"
|
||||
}
|
||||
|
||||
// CreateDogAgentBead creates an agent bead for a dog.
|
||||
// Dogs use a different schema than other agents - they use labels for metadata.
|
||||
// Returns the created issue or an error.
|
||||
|
||||
321
internal/cmd/migrate_agents.go
Normal file
321
internal/cmd/migrate_agents.go
Normal file
@@ -0,0 +1,321 @@
|
||||
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 printMigrationResult(r migrationResult) {
|
||||
var icon string
|
||||
switch r.Status {
|
||||
case "migrated", "would migrate":
|
||||
icon = " ✓"
|
||||
case "skipped":
|
||||
icon = " ⊘"
|
||||
case "error":
|
||||
icon = " ✗"
|
||||
}
|
||||
fmt.Printf("%s %s → %s: %s\n", icon, 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)
|
||||
}
|
||||
}
|
||||
95
internal/cmd/migrate_agents_test.go
Normal file
95
internal/cmd/migrate_agents_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
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) {
|
||||
var icon string
|
||||
switch tt.result.Status {
|
||||
case "migrated", "would migrate":
|
||||
icon = "✓"
|
||||
case "skipped":
|
||||
icon = "⊘"
|
||||
case "error":
|
||||
icon = "✗"
|
||||
}
|
||||
if icon != tt.wantIcon {
|
||||
t.Errorf("icon for status %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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user