Add migration inspection tools for AI agents (bd-627d Phase 2)

Implemented:
- bd migrate --inspect --json: Shows migration plan, db state, warnings
- bd info --schema --json: Returns schema details for agents
- Migration invariants: Validates migrations post-execution
- Added ListMigrations() for introspection

Phase 1 (invariants) and Phase 2 (inspection) complete.
Next: Wire up MCP tools in beads-mcp server.

Amp-Thread-ID: https://ampcode.com/threads/T-c4674660-d640-405f-a929-b664e8699a48
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-11-02 14:03:14 -08:00
parent c810a494c6
commit 1abe4e75ad
7 changed files with 796 additions and 89 deletions

View File

@@ -37,6 +37,7 @@ This command:
dryRun, _ := cmd.Flags().GetBool("dry-run")
updateRepoID, _ := cmd.Flags().GetBool("update-repo-id")
toHashIDs, _ := cmd.Flags().GetBool("to-hash-ids")
inspect, _ := cmd.Flags().GetBool("inspect")
// Handle --update-repo-id first
if updateRepoID {
@@ -44,6 +45,12 @@ This command:
return
}
// Handle --inspect flag (show migration plan for AI agents)
if inspect {
handleInspect()
return
}
// Find .beads directory
beadsDir := findBeadsDir()
if beadsDir == "" {
@@ -695,12 +702,196 @@ func cleanupWALFiles(dbPath string) {
_ = os.Remove(shmPath)
}
// handleInspect shows migration plan and database state for AI agent analysis
func handleInspect() {
// Find .beads directory
beadsDir := findBeadsDir()
if beadsDir == "" {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "no_beads_directory",
"message": "No .beads directory found. Run 'bd init' first.",
})
} else {
fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n")
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n")
}
os.Exit(1)
}
// Load config
cfg, err := loadOrCreateConfig(beadsDir)
if err != nil {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "config_load_failed",
"message": err.Error(),
})
} else {
fmt.Fprintf(os.Stderr, "Error: failed to load config: %v\n", err)
}
os.Exit(1)
}
// Check if database exists (don't create it)
targetPath := cfg.DatabasePath(beadsDir)
dbExists := false
if _, err := os.Stat(targetPath); err == nil {
dbExists = true
} else if !os.IsNotExist(err) {
// Stat error (not just "doesn't exist")
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "database_stat_failed",
"message": err.Error(),
})
} else {
fmt.Fprintf(os.Stderr, "Error: failed to check database: %v\n", err)
}
os.Exit(1)
}
// If database doesn't exist, return inspection with defaults
if !dbExists {
result := map[string]interface{}{
"registered_migrations": sqlite.ListMigrations(),
"current_state": map[string]interface{}{
"schema_version": "missing",
"issue_count": 0,
"config": map[string]string{},
"missing_config": []string{},
"db_exists": false,
},
"warnings": []string{"Database does not exist - run 'bd init' first"},
"invariants_to_check": sqlite.GetInvariantNames(),
}
if jsonOutput {
outputJSON(result)
} else {
fmt.Println("\nMigration Inspection")
fmt.Println("====================")
fmt.Println("Database: missing")
fmt.Println("\n⚠ Database does not exist - run 'bd init' first")
}
return
}
// Open database in read-only mode for inspection
store, err := sqlite.New(targetPath)
if err != nil {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "database_open_failed",
"message": err.Error(),
})
} else {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
}
os.Exit(1)
}
defer func() { _ = store.Close() }()
ctx := context.Background()
// Get current schema version
schemaVersion, err := store.GetMetadata(ctx, "bd_version")
if err != nil {
schemaVersion = "unknown"
}
// Get issue count (use efficient COUNT query)
issueCount := 0
if stats, err := store.GetStatistics(ctx); err == nil {
issueCount = stats.TotalIssues
}
// Get config
configMap := make(map[string]string)
prefix, _ := store.GetConfig(ctx, "issue_prefix")
if prefix != "" {
configMap["issue_prefix"] = prefix
}
// Detect missing config
missingConfig := []string{}
if issueCount > 0 && prefix == "" {
missingConfig = append(missingConfig, "issue_prefix")
}
// Get registered migrations (all migrations are idempotent and run on every open)
registeredMigrations := sqlite.ListMigrations()
// Build invariants list
invariantNames := sqlite.GetInvariantNames()
// Generate warnings
warnings := []string{}
if issueCount > 0 && prefix == "" {
// Detect prefix from first issue (efficient query for just 1 issue)
detectedPrefix := ""
if issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}); err == nil && len(issues) > 0 {
detectedPrefix = utils.ExtractIssuePrefix(issues[0].ID)
}
warnings = append(warnings, fmt.Sprintf("issue_prefix config not set - may break commands after migration (detected: %s)", detectedPrefix))
}
if schemaVersion != Version {
warnings = append(warnings, fmt.Sprintf("schema version mismatch (current: %s, expected: %s)", schemaVersion, Version))
}
// Output result
result := map[string]interface{}{
"registered_migrations": registeredMigrations,
"current_state": map[string]interface{}{
"schema_version": schemaVersion,
"issue_count": issueCount,
"config": configMap,
"missing_config": missingConfig,
"db_exists": true,
},
"warnings": warnings,
"invariants_to_check": invariantNames,
}
if jsonOutput {
outputJSON(result)
} else {
// Human-readable output
fmt.Println("\nMigration Inspection")
fmt.Println("====================")
fmt.Printf("Schema Version: %s\n", schemaVersion)
fmt.Printf("Issue Count: %d\n", issueCount)
fmt.Printf("Registered Migrations: %d\n", len(registeredMigrations))
if len(warnings) > 0 {
fmt.Println("\nWarnings:")
for _, w := range warnings {
fmt.Printf(" ⚠ %s\n", w)
}
}
if len(missingConfig) > 0 {
fmt.Println("\nMissing Config:")
for _, k := range missingConfig {
fmt.Printf(" - %s\n", k)
}
}
fmt.Printf("\nInvariants to Check: %d\n", len(invariantNames))
for _, inv := range invariantNames {
fmt.Printf(" ✓ %s\n", inv)
}
fmt.Println()
}
}
func init() {
migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts")
migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration")
migrateCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes")
migrateCmd.Flags().Bool("update-repo-id", false, "Update repository ID (use after changing git remote)")
migrateCmd.Flags().Bool("to-hash-ids", false, "Migrate sequential IDs to hash-based IDs")
migrateCmd.Flags().Bool("inspect", false, "Show migration plan and database state for AI agent analysis")
migrateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output migration statistics in JSON format")
rootCmd.AddCommand(migrateCmd)
}