diff --git a/cmd/bd/cli_fast_test.go b/cmd/bd/cli_fast_test.go index 95816a0094..24f9458d2f 100644 --- a/cmd/bd/cli_fast_test.go +++ b/cmd/bd/cli_fast_test.go @@ -1324,3 +1324,175 @@ func TestCLI_CreateRejectsFlagLikeTitles(t *testing.T) { } }) } + +// TestCLI_CreateNoHistory tests that the --no-history CLI flag is wired through +// to the created issue (GH#2619). A storage-layer test already covers the DB +// semantics; this test verifies the CLI flag is actually parsed and passed. +func TestCLI_CreateNoHistory(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow CLI test in short mode") + } + + t.Run("NoHistoryFlagSetOnCreatedIssue", func(t *testing.T) { + tmpDir := setupCLITestDB(t) + out := runBDInProcess(t, tmpDir, "create", "No-history agent bead", "-p", "2", "--no-history", "--json") + + var result map[string]interface{} + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, out) + } + if result["no_history"] != true { + t.Errorf("Expected no_history=true on created issue, got: %v", result["no_history"]) + } + // Must NOT be ephemeral (mutually exclusive) + if result["ephemeral"] == true { + t.Errorf("no-history issue must not be ephemeral, but ephemeral=true") + } + }) + + t.Run("NoHistoryPersistedAfterShow", func(t *testing.T) { + tmpDir := setupCLITestDB(t) + out := runBDInProcess(t, tmpDir, "create", "No-history bead for show test", "-p", "2", "--no-history", "--json") + + var created map[string]interface{} + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatalf("Failed to parse create output: %v\nOutput: %s", err, out) + } + id := created["id"].(string) + + showOut := runBDInProcess(t, tmpDir, "show", id, "--json") + var issues []map[string]interface{} + if err := json.Unmarshal([]byte(showOut), &issues); err != nil { + t.Fatalf("Failed to parse show output: %v\nOutput: %s", err, showOut) + } + if len(issues) == 0 { + t.Fatalf("show returned no issues for id %s", id) + } + if issues[0]["no_history"] != true { + t.Errorf("Expected no_history=true after show, got: %v", issues[0]["no_history"]) + } + }) + + t.Run("EphemeralAndNoHistoryMutuallyExclusive", func(t *testing.T) { + tmpDir := setupCLITestDB(t) + _, stderr, err := runBDInProcessAllowError(t, tmpDir, "create", "Should fail", "--ephemeral", "--no-history") + if err == nil { + t.Error("Expected error when combining --ephemeral and --no-history, got none") + } + if !strings.Contains(stderr, "mutually exclusive") { + t.Errorf("Expected 'mutually exclusive' in stderr, got: %s", stderr) + } + }) +} + +// TestCLI_WispListTypeFilter tests that bd mol wisp list --type filters correctly. +func TestCLI_WispListTypeFilter(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow CLI test in short mode") + } + + tmpDir := setupCLITestDB(t) + + // Create two ephemeral wisps of different built-in types + runBDInProcess(t, tmpDir, "create", "Bug wisp", "--ephemeral", "--type", "bug", "-p", "2") + runBDInProcess(t, tmpDir, "create", "Task wisp", "--ephemeral", "--type", "task", "-p", "2") + + // --type bug should return only the bug wisp + out := runBDInProcess(t, tmpDir, "mol", "wisp", "list", "--type", "bug", "--json") + var result map[string]interface{} + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, out) + } + wisps, ok := result["wisps"].([]interface{}) + if !ok { + t.Fatalf("Expected 'wisps' array in JSON output, got: %v", result) + } + if len(wisps) != 1 { + t.Errorf("Expected 1 wisp with type=bug, got %d: %v", len(wisps), wisps) + } + if len(wisps) == 1 { + w := wisps[0].(map[string]interface{}) + if w["type"] != "bug" { + t.Errorf("Expected wisp type=bug, got: %v", w["type"]) + } + } + + // --type task should return only the task wisp + out = runBDInProcess(t, tmpDir, "mol", "wisp", "list", "--type", "task", "--json") + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, out) + } + wisps, ok = result["wisps"].([]interface{}) + if !ok { + t.Fatalf("Expected 'wisps' array, got: %v", result) + } + if len(wisps) != 1 { + t.Errorf("Expected 1 wisp with type=task, got %d", len(wisps)) + } + if len(wisps) == 1 { + w := wisps[0].(map[string]interface{}) + if w["type"] != "task" { + t.Errorf("Expected wisp type=task, got: %v", w["type"]) + } + } + + // No --type filter returns both + out = runBDInProcess(t, tmpDir, "mol", "wisp", "list", "--json") + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, out) + } + wisps = result["wisps"].([]interface{}) + if len(wisps) != 2 { + t.Errorf("Expected 2 wisps without type filter, got %d", len(wisps)) + } +} + +// TestCLI_WispGCExcludeType tests that bd mol wisp gc --exclude-type skips +// wisps of the excluded type during garbage collection. +func TestCLI_WispGCExcludeType(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow CLI test in short mode") + } + + tmpDir := setupCLITestDB(t) + + // Create two ephemeral wisps of different built-in types. + // Use --age=0s so that ALL wisps (regardless of actual age) are treated + // as abandoned GC candidates. + bugOut := runBDInProcess(t, tmpDir, "create", "Bug wisp to keep", "--ephemeral", "--type", "bug", "-p", "2", "--json") + taskOut := runBDInProcess(t, tmpDir, "create", "Task wisp to gc", "--ephemeral", "--type", "task", "-p", "2", "--json") + + var bugCreated, taskCreated map[string]interface{} + if err := json.Unmarshal([]byte(bugOut), &bugCreated); err != nil { + t.Fatalf("Failed to parse bug create output: %v\nOutput: %s", err, bugOut) + } + if err := json.Unmarshal([]byte(taskOut), &taskCreated); err != nil { + t.Fatalf("Failed to parse task create output: %v\nOutput: %s", err, taskOut) + } + bugID := bugCreated["id"].(string) + taskID := taskCreated["id"].(string) + + // Dry-run GC with --exclude-type bug, --age=0s (treats all wisps as abandoned). + // The WispGCResult dry-run JSON uses cleaned_ids to list what would be deleted. + gcOut := runBDInProcess(t, tmpDir, "mol", "wisp", "gc", "--exclude-type", "bug", "--age", "0s", "--dry-run", "--json") + var gcResult map[string]interface{} + if err := json.Unmarshal([]byte(gcOut), &gcResult); err != nil { + t.Fatalf("Failed to parse gc JSON: %v\nOutput: %s", err, gcOut) + } + + // cleaned_ids holds the IDs that would be deleted (dry_run=true). + cleanedRaw, _ := gcResult["cleaned_ids"].([]interface{}) + cleanedIDs := make(map[string]bool, len(cleanedRaw)) + for _, v := range cleanedRaw { + if id, ok := v.(string); ok { + cleanedIDs[id] = true + } + } + + if !cleanedIDs[taskID] { + t.Errorf("Expected task wisp %s in GC candidates (not excluded), got cleaned_ids: %v", taskID, cleanedRaw) + } + if cleanedIDs[bugID] { + t.Errorf("Bug wisp %s should be excluded from GC via --exclude-type bug, but appeared in cleaned_ids", bugID) + } +} diff --git a/cmd/bd/create.go b/cmd/bd/create.go index 5ec46b55bb..aaf26e5006 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -131,6 +131,10 @@ var createCmd = &cobra.Command{ rigOverride, _ := cmd.Flags().GetString("rig") prefixOverride, _ := cmd.Flags().GetString("prefix") wisp, _ := cmd.Flags().GetBool("ephemeral") + noHistory, _ := cmd.Flags().GetBool("no-history") + if wisp && noHistory { + FatalError("--ephemeral and --no-history are mutually exclusive") + } molTypeStr, _ := cmd.Flags().GetString("mol-type") var molType types.MolType if molTypeStr != "" { @@ -241,6 +245,7 @@ var createCmd = &cobra.Command{ Assignee: assignee, ExternalRef: externalRefPtr, Ephemeral: wisp, + NoHistory: noHistory, CreatedBy: getActorWithGit(), Owner: getOwner(), MolType: molType, @@ -315,7 +320,7 @@ var createCmd = &cobra.Command{ // Found a matching route - auto-route to that rig rigName := routing.ExtractProjectFromPath(route.Path) if rigName != "" { - createInRig(cmd, rigName, explicitID, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, specID, wisp) + createInRig(cmd, rigName, explicitID, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, specID, wisp, noHistory) return } } @@ -335,7 +340,7 @@ var createCmd = &cobra.Command{ targetRig = prefixOverride } if targetRig != "" { - createInRig(cmd, targetRig, explicitID, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, specID, wisp) + createInRig(cmd, targetRig, explicitID, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, specID, wisp, noHistory) return } @@ -526,6 +531,7 @@ var createCmd = &cobra.Command{ ExternalRef: externalRefPtr, EstimatedMinutes: estimatedMinutes, Ephemeral: wisp, + NoHistory: noHistory, CreatedBy: getActorWithGit(), Owner: getOwner(), MolType: molType, @@ -808,6 +814,7 @@ func init() { createCmd.Flags().String("prefix", "", "Create issue in rig by prefix (e.g., --prefix bd- or --prefix bd or --prefix beads)") createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)") createCmd.Flags().Bool("ephemeral", false, "Create as ephemeral (short-lived, subject to TTL compaction)") + createCmd.Flags().Bool("no-history", false, "Skip Dolt commit history without making GC-eligible (for permanent agent beads)") createCmd.Flags().String("mol-type", "", "Molecule type: swarm (multi-polecat), patrol (recurring ops), work (default)") createCmd.Flags().String("wisp-type", "", "Wisp type for TTL-based compaction: heartbeat, ping, patrol, gc_report, recovery, error, escalation") createCmd.Flags().Bool("validate", false, "Validate description contains required sections for issue type") @@ -835,7 +842,7 @@ func init() { // createInRig creates an issue in a different rig using --rig flag or auto-routing. // This directly creates in the target rig's database. -func createInRig(cmd *cobra.Command, rigName, explicitID, title, description, issueType string, priority int, design, acceptance, notes, assignee string, labels []string, externalRef, specID string, wisp bool) { +func createInRig(cmd *cobra.Command, rigName, explicitID, title, description, issueType string, priority int, design, acceptance, notes, assignee string, labels []string, externalRef, specID string, wisp, noHistory bool) { ctx := rootCtx // Find the town-level beads directory (where routes.jsonl lives) @@ -952,6 +959,7 @@ func createInRig(cmd *cobra.Command, rigName, explicitID, title, description, is Assignee: assignee, ExternalRef: externalRefPtr, Ephemeral: wisp, + NoHistory: noHistory, CreatedBy: getActorWithGit(), Owner: getOwner(), // Event fields (bd-xwvo fix) diff --git a/cmd/bd/export_test.go b/cmd/bd/export_test.go index 5f2e305c86..2a29b268d4 100644 --- a/cmd/bd/export_test.go +++ b/cmd/bd/export_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/steveyegge/beads/internal/testutil" + "github.com/steveyegge/beads/internal/types" ) func TestExportToFile(t *testing.T) { @@ -401,3 +402,136 @@ func TestFilterOutPollution(t *testing.T) { } } } + +func TestExportNoHistoryBeadRoundTrip(t *testing.T) { + // GH#2619: NoHistory beads are stored in the wisps table. The JSONL export + // must include them with no_history=true, and import must preserve the flag. + // If no_history is dropped during import, the bead becomes GC-eligible. + if testDoltServerPort == 0 { + t.Skip("Dolt test server not available") + } + if testutil.DoltContainerCrashed() { + t.Skipf("Dolt test server crashed: %v", testutil.DoltContainerCrashError()) + } + + ensureTestMode(t) + saved := saveAndRestoreGlobals(t) + _ = saved + + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + origWd, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origWd) }) + + dbName := uniqueTestDBName(t) + testDBPath := filepath.Join(beadsDir, "dolt") + writeTestMetadata(t, testDBPath, dbName) + s := newTestStore(t, testDBPath) + store = s + storeMutex.Lock() + storeActive = true + storeMutex.Unlock() + t.Cleanup(func() { + store = nil + storeMutex.Lock() + storeActive = false + storeMutex.Unlock() + }) + + ctx := context.Background() + rootCtx = ctx + + // Create a NoHistory bead using the store API (routes to wisps table with no_history=1). + noHistoryBead := &types.Issue{ + Title: "NoHistory export test bead", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + NoHistory: true, + } + if err := s.CreateIssue(ctx, noHistoryBead, "test"); err != nil { + t.Fatalf("CreateIssue (NoHistory): %v", err) + } + + // Also create a regular issue to ensure the export contains both. + if _, err := s.DB().ExecContext(ctx, `INSERT INTO issues (id, title, description, design, acceptance_criteria, notes, status, priority, issue_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "nohistory-regular-1", "Regular issue", "", "", "", "", "open", 1, "task"); err != nil { + t.Fatalf("insert regular issue: %v", err) + } + + // Export to file. + exportFile := filepath.Join(tmpDir, "nohistory_export.jsonl") + exportOutput = exportFile + exportAll = true // include everything + exportIncludeInfra = false + exportScrub = false + t.Cleanup(func() { + exportOutput = "" + exportAll = false + }) + + if err := runExport(nil, nil); err != nil { + t.Fatalf("runExport: %v", err) + } + + data, err := os.ReadFile(exportFile) + if err != nil { + t.Fatalf("read export file: %v", err) + } + + // Verify the NoHistory bead appears in the export with no_history=true. + lines := splitJSONL(data) + if len(lines) < 2 { + t.Fatalf("expected at least 2 lines in export (regular + NoHistory), got %d", len(lines)) + } + + var noHistoryLine map[string]interface{} + for _, line := range lines { + var rec map[string]interface{} + if err := json.Unmarshal(line, &rec); err != nil { + t.Fatalf("parse exported JSONL: %v", err) + } + if rec["title"] == "NoHistory export test bead" { + noHistoryLine = rec + break + } + } + if noHistoryLine == nil { + t.Fatal("NoHistory bead not found in exported JSONL — export missed wisps with no_history=true") + } + if noHistoryLine["no_history"] != true { + t.Errorf("exported NoHistory bead has no_history=%v, want true", noHistoryLine["no_history"]) + } + + // Import the exported JSONL into a fresh store and verify no_history survives. + tmpDir2 := t.TempDir() + dbPath2 := filepath.Join(tmpDir2, "dolt") + store2 := newTestStore(t, dbPath2) + + count, err := importFromLocalJSONL(ctx, store2, exportFile) + if err != nil { + t.Fatalf("importFromLocalJSONL: %v", err) + } + if count < 2 { + t.Errorf("expected at least 2 issues imported, got %d", count) + } + + // Retrieve the NoHistory bead from the new store and check the flag. + imported, err := store2.GetIssue(ctx, noHistoryBead.ID) + if err != nil { + t.Fatalf("GetIssue(%s) after import: %v", noHistoryBead.ID, err) + } + if !imported.NoHistory { + t.Error("no_history=true was lost during export→import roundtrip: bead is now GC-eligible") + } + if imported.Ephemeral { + t.Error("NoHistory bead must not become ephemeral=true after roundtrip") + } +} diff --git a/cmd/bd/import_from_jsonl_test.go b/cmd/bd/import_from_jsonl_test.go index f19b645fc2..0d78842fb8 100644 --- a/cmd/bd/import_from_jsonl_test.go +++ b/cmd/bd/import_from_jsonl_test.go @@ -332,4 +332,48 @@ func TestImportFromLocalJSONL(t *testing.T) { t.Errorf("Expected 2 comments after re-import, got %d (duplicates!)", len(issue.Comments)) } }) + + t.Run("no_history flag survives JSONL import roundtrip", func(t *testing.T) { + // Regression test for GH#2619: ImportFromLocalJSONL must preserve no_history=true. + // NoHistory beads are stored in the wisps table with no_history=1. The issueops + // InsertIssueIntoTable function must include no_history in the INSERT or the flag + // is silently dropped and the bead becomes GC-eligible after restore. + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "dolt") + store := newTestStore(t, dbPath) + + // JSONL line with no_history=true (and ephemeral=false). + // This represents a NoHistory bead exported from a live database. + jsonlContent := `{"id":"test-nh1","title":"NoHistory bead","type":"task","status":"open","priority":2,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z","no_history":true} +` + jsonlPath := filepath.Join(tmpDir, "issues.jsonl") + if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0644); err != nil { + t.Fatalf("Failed to write JSONL file: %v", err) + } + + ctx := context.Background() + count, err := importFromLocalJSONL(ctx, store, jsonlPath) + if err != nil { + t.Fatalf("importFromLocalJSONL failed: %v", err) + } + if count != 1 { + t.Errorf("Expected 1 issue imported, got %d", count) + } + + // Verify the bead was imported with no_history=true preserved. + issue, err := store.GetIssue(ctx, "test-nh1") + if err != nil { + t.Fatalf("Failed to get NoHistory bead after import: %v", err) + } + if issue.Title != "NoHistory bead" { + t.Errorf("Expected title 'NoHistory bead', got %q", issue.Title) + } + if !issue.NoHistory { + t.Error("no_history=true was lost during JSONL import: bead is now GC-eligible (would be incorrectly collected by wisp GC)") + } + // NoHistory beads must NOT have ephemeral=true (they're not GC-eligible) + if issue.Ephemeral { + t.Error("NoHistory bead must not be ephemeral=true after import") + } + }) } diff --git a/cmd/bd/update.go b/cmd/bd/update.go index c3f26b6511..a68828c8b4 100644 --- a/cmd/bd/update.go +++ b/cmd/bd/update.go @@ -221,15 +221,29 @@ create, update, show, or close operation).`, // Note: storage layer uses "wisp" field name, maps to "ephemeral" column ephemeralChanged := cmd.Flags().Changed("ephemeral") persistentChanged := cmd.Flags().Changed("persistent") + noHistoryChanged := cmd.Flags().Changed("no-history") + historyChanged := cmd.Flags().Changed("history") if ephemeralChanged && persistentChanged { FatalErrorRespectJSON("cannot specify both --ephemeral and --persistent flags") } + if noHistoryChanged && ephemeralChanged { + FatalErrorRespectJSON("cannot specify both --no-history and --ephemeral flags") + } + if noHistoryChanged && historyChanged { + FatalErrorRespectJSON("cannot specify both --no-history and --history flags") + } if ephemeralChanged { updates["wisp"] = true } if persistentChanged { updates["wisp"] = false } + if noHistoryChanged { + updates["no_history"] = true + } + if historyChanged { + updates["no_history"] = false + } // Metadata flag (GH#1413) if cmd.Flags().Changed("metadata") { metadataValue, _ := cmd.Flags().GetString("metadata") @@ -590,6 +604,8 @@ func init() { // Ephemeral/persistent flags updateCmd.Flags().Bool("ephemeral", false, "Mark issue as ephemeral (wisp) - not exported to JSONL") updateCmd.Flags().Bool("persistent", false, "Mark issue as persistent (promote wisp to regular issue)") + updateCmd.Flags().Bool("no-history", false, "Mark issue as no-history (skip Dolt commits, not GC-eligible)") + updateCmd.Flags().Bool("history", false, "Clear no-history flag (re-enable Dolt commit history)") // Metadata flag (GH#1413) updateCmd.Flags().String("metadata", "", "Set custom metadata (JSON string or @file.json to read from file)") // Incremental metadata edits (GH#1406) diff --git a/cmd/bd/wisp.go b/cmd/bd/wisp.go index 0d3bf3cfbd..e9b0e97aef 100644 --- a/cmd/bd/wisp.go +++ b/cmd/bd/wisp.go @@ -75,6 +75,8 @@ type WispListItem struct { Title string `json:"title"` Status string `json:"status"` Priority int `json:"priority"` + Type string `json:"type"` + Labels []string `json:"labels,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Old bool `json:"old,omitempty"` // Not updated in 24+ hours @@ -353,6 +355,7 @@ func runWispList(cmd *cobra.Command, args []string) { ctx := rootCtx showAll, _ := cmd.Flags().GetBool("all") + typeFilter, _ := cmd.Flags().GetString("type") // Check for database connection if store == nil { @@ -373,6 +376,10 @@ func runWispList(cmd *cobra.Command, args []string) { Ephemeral: &ephemeralFlag, Limit: 5000, } + if typeFilter != "" { + it := types.IssueType(typeFilter) + filter.IssueType = &it + } issues, err := store.SearchIssues(ctx, "", filter) if err != nil { FatalError("listing wisps: %v", err) @@ -400,6 +407,8 @@ func runWispList(cmd *cobra.Command, args []string) { Title: issue.Title, Status: string(issue.Status), Priority: issue.Priority, + Type: string(issue.IssueType), + Labels: issue.Labels, CreatedAt: issue.CreatedAt, UpdatedAt: issue.UpdatedAt, } @@ -438,9 +447,9 @@ func runWispList(cmd *cobra.Command, args []string) { fmt.Printf("Wisps (%d):\n\n", len(items)) // Print header - fmt.Printf("%-12s %-10s %-4s %-46s %s\n", - "ID", "STATUS", "PRI", "TITLE", "UPDATED") - fmt.Println(strings.Repeat("-", 90)) + fmt.Printf("%-12s %-10s %-4s %-10s %-46s %s\n", + "ID", "STATUS", "PRI", "TYPE", "TITLE", "UPDATED") + fmt.Println(strings.Repeat("-", 100)) for _, item := range items { // Truncate title if too long @@ -458,8 +467,8 @@ func runWispList(cmd *cobra.Command, args []string) { updated = ui.RenderWarn(updated + " ⚠") } - fmt.Printf("%-12s %-10s P%-3d %-46s %s\n", - item.ID, status, item.Priority, title, updated) + fmt.Printf("%-12s %-10s P%-3d %-10s %-46s %s\n", + item.ID, status, item.Priority, item.Type, title, updated) } // Print warnings @@ -519,13 +528,15 @@ Note: This uses time-based cleanup, appropriate for ephemeral wisps. For graph-pressure staleness detection (blocking other work), see 'bd mol stale'. Examples: - bd mol wisp gc # Clean abandoned wisps (default: 1h threshold) - bd mol wisp gc --dry-run # Preview what would be cleaned - bd mol wisp gc --age 24h # Custom age threshold - bd mol wisp gc --all # Also clean closed wisps older than threshold - bd mol wisp gc --closed # Preview closed wisp deletion - bd mol wisp gc --closed --force # Delete all closed wisps - bd mol wisp gc --closed --dry-run # Explicit dry-run (same as no --force)`, + bd mol wisp gc # Clean abandoned wisps (default: 1h threshold) + bd mol wisp gc --dry-run # Preview what would be cleaned + bd mol wisp gc --age 24h # Custom age threshold + bd mol wisp gc --all # Also clean closed wisps older than threshold + bd mol wisp gc --closed # Preview closed wisp deletion + bd mol wisp gc --closed --force # Delete all closed wisps + bd mol wisp gc --closed --dry-run # Explicit dry-run (same as no --force) + bd mol wisp gc --exclude-type agent,rig # Protect agent and rig wisps from GC + bd mol wisp gc --closed --force --exclude-type mol # Delete closed wisps except mol type`, Run: runWispGC, } @@ -547,6 +558,7 @@ func runWispGC(cmd *cobra.Command, args []string) { cleanAll, _ := cmd.Flags().GetBool("all") closedMode, _ := cmd.Flags().GetBool("closed") force, _ := cmd.Flags().GetBool("force") + excludeTypeStrs, _ := cmd.Flags().GetStringSlice("exclude-type") // Parse age threshold ageThreshold := time.Hour // Default 1 hour @@ -563,17 +575,24 @@ func runWispGC(cmd *cobra.Command, args []string) { FatalErrorWithHint("no database connection", "check 'bd doctor' and 'bd dolt status' for configuration issues") } + // Convert string slice to []types.IssueType + var excludeTypes []types.IssueType + for _, t := range excludeTypeStrs { + excludeTypes = append(excludeTypes, types.IssueType(t)) + } + // --closed mode: purge all closed wisps (batch deletion) if closedMode { - runWispPurgeClosed(ctx, dryRun, force) + runWispPurgeClosed(ctx, dryRun, force, excludeTypes) return } // Query wisps from main database using Ephemeral filter ephemeralFlag := true filter := types.IssueFilter{ - Ephemeral: &ephemeralFlag, - Limit: 5000, + Ephemeral: &ephemeralFlag, + ExcludeTypes: excludeTypes, + Limit: 5000, } issues, err := store.SearchIssues(ctx, "", filter) if err != nil { @@ -685,14 +704,15 @@ func runWispGC(cmd *cobra.Command, args []string) { // runWispPurgeClosed deletes all closed wisps using batch deletion. // Safe by default: preview-only without --force. -func runWispPurgeClosed(ctx context.Context, dryRun bool, force bool) { +func runWispPurgeClosed(ctx context.Context, dryRun bool, force bool, excludeTypes []types.IssueType) { // Query closed ephemeral issues statusClosed := types.StatusClosed ephemeralTrue := true filter := types.IssueFilter{ - Status: &statusClosed, - Ephemeral: &ephemeralTrue, - Limit: 5000, + Status: &statusClosed, + Ephemeral: &ephemeralTrue, + ExcludeTypes: excludeTypes, + Limit: 5000, } closedIssues, err := store.SearchIssues(ctx, "", filter) @@ -784,12 +804,14 @@ func init() { wispCreateCmd.Flags().Bool("root-only", false, "Create only the root issue (no child step issues)") wispListCmd.Flags().Bool("all", false, "Include closed wisps") + wispListCmd.Flags().String("type", "", "Filter by issue type (e.g., agent, task, patrol)") wispGCCmd.Flags().Bool("dry-run", false, "Preview what would be cleaned") wispGCCmd.Flags().String("age", "1h", "Age threshold for abandoned wisp detection") wispGCCmd.Flags().Bool("all", false, "Also clean closed wisps older than threshold") wispGCCmd.Flags().Bool("closed", false, "Delete all closed wisps (ignores --age threshold)") wispGCCmd.Flags().BoolP("force", "f", false, "Actually delete (default: preview only)") + wispGCCmd.Flags().StringSlice("exclude-type", nil, "Exclude wisps of these types from GC (comma-separated, e.g., agent,rig)") wispCmd.AddCommand(wispCreateCmd) wispCmd.AddCommand(wispListCmd) diff --git a/internal/storage/dolt/demote_to_wisp_test.go b/internal/storage/dolt/demote_to_wisp_test.go new file mode 100644 index 0000000000..b751613b20 --- /dev/null +++ b/internal/storage/dolt/demote_to_wisp_test.go @@ -0,0 +1,243 @@ +package dolt + +import ( + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +// TestDemoteToWisp_NoHistory verifies that UpdateIssue with no_history=true +// migrates the issue from the issues table to the wisps table. (be-x4l) +func TestDemoteToWisp_NoHistory(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx, cancel := testContext(t) + defer cancel() + + // Create a regular (versioned) issue. + issue := &types.Issue{ + Title: "will become no-history", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "tester"); err != nil { + t.Fatalf("create issue: %v", err) + } + id := issue.ID + + // Verify it lives in the issues table before demotion. + if store.isActiveWisp(ctx, id) { + t.Fatalf("issue %s should NOT be in wisps table before demotion", id) + } + + // Apply --no-history via UpdateIssue. + if err := store.UpdateIssue(ctx, id, map[string]interface{}{ + "no_history": true, + }, "tester"); err != nil { + t.Fatalf("UpdateIssue with no_history=true: %v", err) + } + + // The issue must now live in the wisps table. + if !store.isActiveWisp(ctx, id) { + t.Errorf("issue %s should be in wisps table after no_history update, but is not", id) + } + + // Verify it is gone from the issues table. + var count int + if err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM issues WHERE id = ?", id).Scan(&count); err != nil { + t.Fatalf("query issues table: %v", err) + } + if count != 0 { + t.Errorf("issue %s still present in issues table after demotion (count=%d)", id, count) + } + + // Verify the wisp has the NoHistory flag set. + wisp, err := store.GetIssue(ctx, id) + if err != nil { + t.Fatalf("GetIssue after demotion: %v", err) + } + if !wisp.NoHistory { + t.Errorf("wisp %s: NoHistory should be true after demotion", id) + } + + // Verify title is preserved. + if wisp.Title != issue.Title { + t.Errorf("wisp %s: title mismatch: got %q, want %q", id, wisp.Title, issue.Title) + } +} + +// TestDemoteToWisp_Ephemeral verifies that UpdateIssue with wisp=true +// migrates the issue from the issues table to the wisps table. (be-x4l) +func TestDemoteToWisp_Ephemeral(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx, cancel := testContext(t) + defer cancel() + + // Create a regular (versioned) issue. + issue := &types.Issue{ + Title: "will become ephemeral", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeBug, + } + if err := store.CreateIssue(ctx, issue, "tester"); err != nil { + t.Fatalf("create issue: %v", err) + } + id := issue.ID + + // Apply --ephemeral via UpdateIssue (wisp=true). + if err := store.UpdateIssue(ctx, id, map[string]interface{}{ + "wisp": true, + }, "tester"); err != nil { + t.Fatalf("UpdateIssue with wisp=true: %v", err) + } + + // The issue must now live in the wisps table. + if !store.isActiveWisp(ctx, id) { + t.Errorf("issue %s should be in wisps table after wisp update, but is not", id) + } + + // Verify the wisp has Ephemeral set. + wisp, err := store.GetIssue(ctx, id) + if err != nil { + t.Fatalf("GetIssue after demotion: %v", err) + } + if !wisp.Ephemeral { + t.Errorf("wisp %s: Ephemeral should be true after demotion", id) + } +} + +// TestDemoteToWisp_FieldUpdatesApplied verifies that other field updates +// (e.g., title, status) are applied alongside the no_history migration. (be-x4l) +func TestDemoteToWisp_FieldUpdatesApplied(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx, cancel := testContext(t) + defer cancel() + + issue := &types.Issue{ + Title: "original title", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "tester"); err != nil { + t.Fatalf("create issue: %v", err) + } + id := issue.ID + + // Demote with simultaneous field update. + if err := store.UpdateIssue(ctx, id, map[string]interface{}{ + "no_history": true, + "title": "updated title", + "status": "in_progress", + }, "tester"); err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + + wisp, err := store.GetIssue(ctx, id) + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if wisp.Title != "updated title" { + t.Errorf("title: got %q, want %q", wisp.Title, "updated title") + } + if wisp.Status != "in_progress" { + t.Errorf("status: got %q, want %q", wisp.Status, "in_progress") + } + if !wisp.NoHistory { + t.Error("NoHistory should be true") + } +} + +// TestDemoteToWisp_LabelsPreserved verifies that labels are migrated +// from the permanent labels table to wisp_labels during demotion. (be-x4l) +func TestDemoteToWisp_LabelsPreserved(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx, cancel := testContext(t) + defer cancel() + + issue := &types.Issue{ + Title: "labeled issue", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "tester"); err != nil { + t.Fatalf("create issue: %v", err) + } + id := issue.ID + + // Add labels before demotion. + if err := store.AddLabel(ctx, id, "priority-hot", "tester"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + if err := store.AddLabel(ctx, id, "review", "tester"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + + // Demote to wisp. + if err := store.UpdateIssue(ctx, id, map[string]interface{}{ + "no_history": true, + }, "tester"); err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + + // Verify labels appear on the wisp. + wisp, err := store.GetIssue(ctx, id) + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + labelSet := make(map[string]bool, len(wisp.Labels)) + for _, l := range wisp.Labels { + labelSet[l] = true + } + for _, want := range []string{"priority-hot", "review"} { + if !labelSet[want] { + t.Errorf("label %q missing from wisp after demotion; labels=%v", want, wisp.Labels) + } + } +} + +// TestDemoteToWisp_AlreadyInWisps verifies that calling UpdateIssue with +// no_history=true on a wisp (already in wisps table) does not error and +// behaves as a normal wisp update. (be-x4l) +func TestDemoteToWisp_AlreadyInWisps(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx, cancel := testContext(t) + defer cancel() + + // Create an already-ephemeral issue. + issue := &types.Issue{ + Title: "already a wisp", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + Ephemeral: true, + } + if err := store.CreateIssue(ctx, issue, "tester"); err != nil { + t.Fatalf("create wisp: %v", err) + } + id := issue.ID + + // Calling UpdateIssue with wisp=true on an existing wisp should not error. + if err := store.UpdateIssue(ctx, id, map[string]interface{}{ + "wisp": true, + }, "tester"); err != nil { + t.Fatalf("UpdateIssue on existing wisp: %v", err) + } + + // Should still be a wisp. + if !store.isActiveWisp(ctx, id) { + t.Errorf("issue %s should still be a wisp after redundant wisp=true update", id) + } +} diff --git a/internal/storage/dolt/ephemeral_routing.go b/internal/storage/dolt/ephemeral_routing.go index 668a51ad57..53783de5ec 100644 --- a/internal/storage/dolt/ephemeral_routing.go +++ b/internal/storage/dolt/ephemeral_routing.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "strings" + "time" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" @@ -273,6 +274,174 @@ func (s *DoltStore) PromoteFromEphemeral(ctx context.Context, id string, actor s return s.deleteWisp(ctx, id) } +// DemoteToWisp moves an issue from the issues table to the wisps table. +// This is the inverse of PromoteFromEphemeral. It applies any provided updates +// (e.g., setting no_history or ephemeral) to the issue in-memory, then migrates +// it atomically: insert into wisps, copy auxiliary data, delete from issues. +// +// Called by UpdateIssue when no_history=true or wisp=true is set on a regular issue. +func (s *DoltStore) DemoteToWisp(ctx context.Context, id string, updates map[string]interface{}, actor string) error { + // Read the current issue from the issues table. + issue, err := scanIssueFromTable(ctx, s.db, "issues", id) + if err != nil { + return fmt.Errorf("failed to get issue for demotion: %w", err) + } + + // Apply in-memory updates so the wisps row reflects all requested changes. + applyUpdatesToIssueStruct(issue, updates) + + // Begin a single transaction for the insert + delete + dolt commit. + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Insert into wisps table. + if err := insertIssueTxIntoTable(ctx, tx, "wisps", issue); err != nil { + return fmt.Errorf("failed to insert issue into wisps: %w", err) + } + + // Copy labels: labels → wisp_labels. + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO wisp_labels (issue_id, label) + SELECT issue_id, label FROM labels WHERE issue_id = ? + `, id); err != nil { + log.Printf("demote %s: failed to copy labels (data may be lost): %v", id, err) + } + + // Copy dependencies: dependencies → wisp_dependencies. + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO wisp_dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) + SELECT issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id + FROM dependencies WHERE issue_id = ? + `, id); err != nil { + log.Printf("demote %s: failed to copy dependencies (data may be lost): %v", id, err) + } + + // Copy events: events → wisp_events. + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO wisp_events (issue_id, event_type, actor, old_value, new_value, comment, created_at) + SELECT issue_id, event_type, actor, old_value, new_value, comment, created_at + FROM events WHERE issue_id = ? + `, id); err != nil { + log.Printf("demote %s: failed to copy events (data may be lost): %v", id, err) + } + + // Copy comments: comments → wisp_comments. + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO wisp_comments (issue_id, author, text, created_at) + SELECT issue_id, author, text, created_at + FROM comments WHERE issue_id = ? + `, id); err != nil { + log.Printf("demote %s: failed to copy comments (data may be lost): %v", id, err) + } + + // Record a demotion event in wisp_events. + if _, err := tx.ExecContext(ctx, ` + INSERT INTO wisp_events (issue_id, event_type, actor, old_value, new_value) + VALUES (?, ?, ?, ?, ?) + `, id, types.EventUpdated, actor, "", "demoted to wisp"); err != nil { + log.Printf("demote %s: failed to record demotion event: %v", id, err) + } + + // Delete from permanent auxiliary tables. + for _, table := range []string{"dependencies", "events", "comments", "labels"} { + if _, err := tx.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE issue_id = ?", table), id); //nolint:gosec // G201: table is hardcoded + err != nil { + return fmt.Errorf("failed to delete from %s: %w", table, err) + } + } + + // Delete from issues table. + if _, err := tx.ExecContext(ctx, "DELETE FROM issues WHERE id = ?", id); err != nil { + return fmt.Errorf("failed to delete issue from issues: %w", err) + } + + // Dolt commit to record the removal from versioned tables. + for _, table := range []string{"issues", "labels", "dependencies", "events", "comments"} { + _, _ = tx.ExecContext(ctx, "CALL DOLT_ADD(?)", table) + } + commitMsg := fmt.Sprintf("bd: demote %s to wisp", id) + if _, err := tx.ExecContext(ctx, "CALL DOLT_COMMIT('-m', ?, '--author', ?)", + commitMsg, s.commitAuthorString()); err != nil && !isDoltNothingToCommit(err) { + return fmt.Errorf("dolt commit after demotion: %w", err) + } + + return wrapTransactionError("commit demote to wisp", tx.Commit()) +} + +// applyUpdatesToIssueStruct applies an updates map (as used by UpdateIssue) to +// an Issue struct in memory. Used by DemoteToWisp so the wisps row reflects all +// requested field changes alongside the routing-flag change. +func applyUpdatesToIssueStruct(issue *types.Issue, updates map[string]interface{}) { + now := time.Now().UTC() + issue.UpdatedAt = now + + for key, value := range updates { + switch key { + case "status": + if v, ok := value.(string); ok { + issue.Status = types.Status(v) + } + case "title": + if v, ok := value.(string); ok { + issue.Title = v + } + case "description": + if v, ok := value.(string); ok { + issue.Description = v + } + case "design": + if v, ok := value.(string); ok { + issue.Design = v + } + case "notes": + if v, ok := value.(string); ok { + issue.Notes = v + } + case "assignee": + if v, ok := value.(string); ok { + issue.Assignee = v + } + case "priority": + if v, ok := value.(int); ok { + issue.Priority = v + } + case "issue_type": + if v, ok := value.(string); ok { + issue.IssueType = types.IssueType(v) + } + case "wisp": + if v, ok := value.(bool); ok { + issue.Ephemeral = v + } + case "no_history": + if v, ok := value.(bool); ok { + issue.NoHistory = v + } + case "acceptance_criteria": + if v, ok := value.(string); ok { + issue.AcceptanceCriteria = v + } + case "external_ref": + if v, ok := value.(string); ok { + issue.ExternalRef = &v + } + case "spec_id": + if v, ok := value.(string); ok { + issue.SpecID = v + } + case "estimated_minutes": + if v, ok := value.(int); ok { + issue.EstimatedMinutes = &v + } + } + // closed_at is managed by manageClosedAt; skip here. + // Labels, metadata, and other complex fields are handled outside this path. + } +} + // getAllWispDependencyRecords returns all wisp dependency records, keyed by issue_id. // Used by DetectCycles to include wisp dependencies in cross-table cycle detection. (bd-xe27) func (s *DoltStore) getAllWispDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error) { diff --git a/internal/storage/dolt/filters.go b/internal/storage/dolt/filters.go index 764f80a25d..448d0b4ca1 100644 --- a/internal/storage/dolt/filters.go +++ b/internal/storage/dolt/filters.go @@ -103,14 +103,13 @@ func buildIssueFilterClauses(query string, filter types.IssueFilter, tables filt whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT id FROM %s WHERE issue_type = ?)", tables.main)) args = append(args, *filter.IssueType) } - // Use subquery for type exclusion to prevent Dolt mergeJoinIter panic (same as above). if len(filter.ExcludeTypes) > 0 { placeholders := make([]string, len(filter.ExcludeTypes)) for i, t := range filter.ExcludeTypes { placeholders[i] = "?" args = append(args, string(t)) } - whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT id FROM %s WHERE issue_type NOT IN (%s))", tables.main, strings.Join(placeholders, ","))) + whereClauses = append(whereClauses, fmt.Sprintf("issue_type NOT IN (%s)", strings.Join(placeholders, ","))) } // Assignee diff --git a/internal/storage/dolt/issue_scan.go b/internal/storage/dolt/issue_scan.go index eeb5fdf277..562dea0707 100644 --- a/internal/storage/dolt/issue_scan.go +++ b/internal/storage/dolt/issue_scan.go @@ -14,7 +14,7 @@ const issueSelectColumns = `id, content_hash, title, description, design, accept status, priority, issue_type, assignee, estimated_minutes, created_at, created_by, owner, updated_at, closed_at, external_ref, spec_id, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, - sender, ephemeral, wisp_type, pinned, is_template, crystallizes, + sender, ephemeral, no_history, wisp_type, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters, hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type, event_kind, actor, target, payload, @@ -42,7 +42,7 @@ func scanIssueFrom(s issueScanner) (*types.Issue, error) { var sender, wispType, molType, eventKind, actor, target, payload sql.NullString var awaitType, awaitID, waiters sql.NullString var hookBead, roleBead, agentState, roleType, rig sql.NullString - var ephemeral, pinned, isTemplate, crystallizes sql.NullInt64 + var ephemeral, noHistory, pinned, isTemplate, crystallizes sql.NullInt64 var qualityScore sql.NullFloat64 var metadata sql.NullString @@ -52,7 +52,7 @@ func scanIssueFrom(s issueScanner) (*types.Issue, error) { &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &createdAtStr, &createdBy, &owner, &updatedAtStr, &closedAt, &externalRef, &specID, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, - &sender, &ephemeral, &wispType, &pinned, &isTemplate, &crystallizes, + &sender, &ephemeral, &noHistory, &wispType, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, &hookBead, &roleBead, &agentState, &lastActivity, &roleType, &rig, &molType, &eventKind, &actor, &target, &payload, @@ -117,6 +117,9 @@ func scanIssueFrom(s issueScanner) (*types.Issue, error) { if ephemeral.Valid && ephemeral.Int64 != 0 { issue.Ephemeral = true } + if noHistory.Valid && noHistory.Int64 != 0 { + issue.NoHistory = true + } if wispType.Valid { issue.WispType = types.WispType(wispType.String) } diff --git a/internal/storage/dolt/issues.go b/internal/storage/dolt/issues.go index 810ef9b52d..6bfbfeb49e 100644 --- a/internal/storage/dolt/issues.go +++ b/internal/storage/dolt/issues.go @@ -22,9 +22,10 @@ func (s *DoltStore) CreateIssue(ctx context.Context, issue *types.Issue, actor s if issue == nil { return fmt.Errorf("issue must not be nil") } - // Route ephemeral issues and infra types to wisps table. - if issue.Ephemeral || s.IsInfraTypeCtx(ctx, issue.IssueType) { - issue.Ephemeral = true + // Route to wisps table if ephemeral, no-history, or infra type. + useWispsTable := issue.Ephemeral || issue.NoHistory || s.IsInfraTypeCtx(ctx, issue.IssueType) + if useWispsTable && !issue.NoHistory { + issue.Ephemeral = true // infra types get marked ephemeral (legacy behavior) } tx, err := s.db.BeginTx(ctx, nil) @@ -45,8 +46,8 @@ func (s *DoltStore) CreateIssue(ctx context.Context, issue *types.Issue, actor s return err } - // Dolt versioning — wisps are transient and skip DOLT_COMMIT. - if !issue.Ephemeral { + // Dolt versioning — wisps and no-history issues skip DOLT_COMMIT. + if !issue.Ephemeral && !issue.NoHistory { // GH#2455: Stage only the tables we modified, then commit without -A // to avoid sweeping up stale config changes from concurrent operations. for _, table := range []string{"issues", "events"} { @@ -79,10 +80,13 @@ func (s *DoltStore) CreateIssuesWithFullOptions(ctx context.Context, issues []*t return nil } - // All-ephemeral fast path: individual transactions, no Dolt versioning. - if issueops.AllEphemeral(issues) { + // All-wisps fast path: individual transactions, no Dolt versioning. + // Covers both ephemeral issues and no-history issues (both skip DOLT_COMMIT). + if issueops.AllWisps(issues) { for _, issue := range issues { - issue.Ephemeral = true + if !issue.NoHistory { + issue.Ephemeral = true + } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) @@ -186,6 +190,15 @@ func (s *DoltStore) UpdateIssue(ctx context.Context, id string, updates map[stri return s.updateWisp(ctx, id, updates, actor) } + // If updating a regular issue to no-history or ephemeral, migrate it to the + // wisps table instead of updating in-place. Table routing only happens at + // create time by default, so we must perform the migration here. (be-x4l) + _, settingNoHistory := updates["no_history"] + _, settingWisp := updates["wisp"] + if settingNoHistory || settingWisp { + return s.DemoteToWisp(ctx, id, updates, actor) + } + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) @@ -1021,7 +1034,7 @@ func isAllowedUpdateField(key string) bool { "issue_type": true, "estimated_minutes": true, "external_ref": true, "spec_id": true, "closed_at": true, "close_reason": true, "closed_by_session": true, "source_repo": true, - "sender": true, "wisp": true, "wisp_type": true, "pinned": true, + "sender": true, "wisp": true, "wisp_type": true, "no_history": true, "pinned": true, "hook_bead": true, "role_bead": true, "agent_state": true, "last_activity": true, "role_type": true, "rig": true, "mol_type": true, "holder": true, "event_category": true, "event_actor": true, "event_target": true, "event_payload": true, diff --git a/internal/storage/dolt/migrations.go b/internal/storage/dolt/migrations.go index 68fc0ad774..db5e407aab 100644 --- a/internal/storage/dolt/migrations.go +++ b/internal/storage/dolt/migrations.go @@ -29,6 +29,7 @@ var migrationsList = []Migration{ {"wisp_dep_type_index", migrations.MigrateWispDepTypeIndex}, {"cleanup_autopush_metadata", migrations.MigrateCleanupAutopushMetadata}, {"uuid_primary_keys", migrations.MigrateUUIDPrimaryKeys}, + {"add_no_history_column", migrations.MigrateAddNoHistoryColumn}, } // RunMigrations executes all registered Dolt migrations in order. diff --git a/internal/storage/dolt/migrations/004_wisps_table.go b/internal/storage/dolt/migrations/004_wisps_table.go index 0f1acf077c..dbe203797c 100644 --- a/internal/storage/dolt/migrations/004_wisps_table.go +++ b/internal/storage/dolt/migrations/004_wisps_table.go @@ -80,6 +80,7 @@ const wispsTableSchema = `CREATE TABLE IF NOT EXISTS wisps ( original_size INT, sender VARCHAR(255) DEFAULT '', ephemeral TINYINT(1) DEFAULT 0, + no_history TINYINT(1) DEFAULT 0, wisp_type VARCHAR(32) DEFAULT '', pinned TINYINT(1) DEFAULT 0, is_template TINYINT(1) DEFAULT 0, diff --git a/internal/storage/dolt/migrations/011_add_no_history_column.go b/internal/storage/dolt/migrations/011_add_no_history_column.go new file mode 100644 index 0000000000..5fee1cb141 --- /dev/null +++ b/internal/storage/dolt/migrations/011_add_no_history_column.go @@ -0,0 +1,31 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateAddNoHistoryColumn adds the no_history column to the issues and wisps tables. +// no_history marks beads stored in the wisps table that should NOT be GC-eligible +// (as opposed to ephemeral wisps which are GC-eligible). Part of gh-2619. +// +// Idempotent: checks for column existence before ALTER. +func MigrateAddNoHistoryColumn(db *sql.DB) error { + for _, table := range []string{"issues", "wisps"} { + exists, err := columnExists(db, table, "no_history") + if err != nil { + return fmt.Errorf("failed to check no_history column on %s: %w", table, err) + } + if exists { + continue + } + + //nolint:gosec // G201: table is from hardcoded list + _, err = db.Exec(fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN no_history TINYINT(1) DEFAULT 0", table)) + if err != nil { + return fmt.Errorf("failed to add no_history column to %s: %w", table, err) + } + } + + return nil +} diff --git a/internal/storage/dolt/schema.go b/internal/storage/dolt/schema.go index 1f7731e1ea..14a680bb81 100644 --- a/internal/storage/dolt/schema.go +++ b/internal/storage/dolt/schema.go @@ -36,6 +36,8 @@ CREATE TABLE IF NOT EXISTS issues ( -- Messaging fields sender VARCHAR(255) DEFAULT '', ephemeral TINYINT(1) DEFAULT 0, + -- NoHistory: stored in wisps table but NOT GC-eligible (gh-2619) + no_history TINYINT(1) DEFAULT 0, -- Wisp classification for TTL-based compaction (gt-9br) wisp_type VARCHAR(32) DEFAULT '', -- Pinned field diff --git a/internal/storage/dolt/transaction.go b/internal/storage/dolt/transaction.go index e380aa84ea..7a13a27558 100644 --- a/internal/storage/dolt/transaction.go +++ b/internal/storage/dolt/transaction.go @@ -153,7 +153,7 @@ func (t *doltTransaction) CreateIssue(ctx context.Context, issue *types.Issue, a } table := "issues" - if issue.Ephemeral { + if issue.Ephemeral || issue.NoHistory { table = "wisps" } diff --git a/internal/storage/dolt/wisp_gc_test.go b/internal/storage/dolt/wisp_gc_test.go index 713bec9c2b..eee68436f5 100644 --- a/internal/storage/dolt/wisp_gc_test.go +++ b/internal/storage/dolt/wisp_gc_test.go @@ -425,6 +425,69 @@ func TestCommitWithConfig_IncludesConfig(t *testing.T) { } } +// TestWispGC_SkipsNoHistoryBeads verifies that wisp GC does NOT collect beads +// with NoHistory=true. NoHistory beads are stored in the wisps table but have +// ephemeral=0, so the GC filter (Ephemeral=true → "ephemeral = 1") must +// exclude them. This is the explicit regression test for gh-2619. +func TestWispGC_SkipsNoHistoryBeads(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx, cancel := testContext(t) + defer cancel() + + // Create a NoHistory bead: stored in wisps table, but NOT GC-eligible. + noHistoryBead := &types.Issue{ + Title: "no-history bead (must survive GC)", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + NoHistory: true, + } + if err := store.CreateIssue(ctx, noHistoryBead, "test"); err != nil { + t.Fatalf("create no-history bead: %v", err) + } + + // Create a normal ephemeral wisp: should be visible to GC. + ephemeralWisp := &types.Issue{ + Title: "normal ephemeral wisp (GC-eligible)", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + Ephemeral: true, + } + if err := store.CreateIssue(ctx, ephemeralWisp, "test"); err != nil { + t.Fatalf("create ephemeral wisp: %v", err) + } + + // Query with Ephemeral=true — the exact filter used by wisp GC. + ephemeralTrue := true + filter := types.IssueFilter{ + Ephemeral: &ephemeralTrue, + Limit: 5000, + } + issues, err := store.SearchIssues(ctx, "", filter) + if err != nil { + t.Fatalf("SearchIssues: %v", err) + } + + // Build set of returned IDs. + found := make(map[string]bool, len(issues)) + for _, iss := range issues { + found[iss.ID] = true + } + + // NoHistory bead must NOT appear in GC query results. + if found[noHistoryBead.ID] { + t.Errorf("GC safety violation: NoHistory bead %s was returned by Ephemeral=true filter", noHistoryBead.ID) + } + + // Normal ephemeral wisp MUST appear (sanity-check that the query works). + if !found[ephemeralWisp.ID] { + t.Errorf("sanity: ephemeral wisp %s was not returned by Ephemeral=true filter", ephemeralWisp.ID) + } +} + // TestFindWispDependentsRecursive_NoDependents verifies wisps with no // dependents return an empty map. func TestFindWispDependentsRecursive_NoDependents(t *testing.T) { diff --git a/internal/storage/dolt/wisps.go b/internal/storage/dolt/wisps.go index 70f01cf82b..8e9d70540a 100644 --- a/internal/storage/dolt/wisps.go +++ b/internal/storage/dolt/wisps.go @@ -30,7 +30,7 @@ func insertIssueIntoTable(ctx context.Context, tx *sql.Tx, table string, issue * status, priority, issue_type, assignee, estimated_minutes, created_at, created_by, owner, updated_at, closed_at, external_ref, spec_id, compaction_level, compacted_at, compacted_at_commit, original_size, - sender, ephemeral, wisp_type, pinned, is_template, crystallizes, + sender, ephemeral, no_history, wisp_type, pinned, is_template, crystallizes, mol_type, work_type, quality_score, source_system, source_repo, close_reason, event_kind, actor, target, payload, await_type, await_id, timeout_ns, waiters, @@ -41,7 +41,7 @@ func insertIssueIntoTable(ctx context.Context, tx *sql.Tx, table string, issue * ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -71,7 +71,7 @@ func insertIssueIntoTable(ctx context.Context, tx *sql.Tx, table string, issue * issue.Status, issue.Priority, issue.IssueType, nullString(issue.Assignee), nullInt(issue.EstimatedMinutes), issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt, issue.ClosedAt, nullStringPtr(issue.ExternalRef), issue.SpecID, issue.CompactionLevel, issue.CompactedAt, nullStringPtr(issue.CompactedAtCommit), nullIntVal(issue.OriginalSize), - issue.Sender, issue.Ephemeral, issue.WispType, issue.Pinned, issue.IsTemplate, issue.Crystallizes, + issue.Sender, issue.Ephemeral, issue.NoHistory, issue.WispType, issue.Pinned, issue.IsTemplate, issue.Crystallizes, issue.MolType, issue.WorkType, issue.QualityScore, issue.SourceSystem, issue.SourceRepo, issue.CloseReason, issue.EventKind, issue.Actor, issue.Target, issue.Payload, issue.AwaitType, issue.AwaitID, issue.Timeout.Nanoseconds(), formatJSONStringArray(issue.Waiters), diff --git a/internal/storage/embeddeddolt/create_issue.go b/internal/storage/embeddeddolt/create_issue.go index 785a7c9e16..2784296a5b 100644 --- a/internal/storage/embeddeddolt/create_issue.go +++ b/internal/storage/embeddeddolt/create_issue.go @@ -46,12 +46,14 @@ func (s *EmbeddedDoltStore) CreateIssuesWithFullOptions(ctx context.Context, iss return nil } - // All-ephemeral fast path: create each wisp individually within + // All-wisps fast path: create each wisp/no-history issue individually within // its own transaction, threading opts through so that callers' // SkipPrefixValidation / OrphanHandling settings are respected. - if issueops.AllEphemeral(issues) { + if issueops.AllWisps(issues) { for _, issue := range issues { - issue.Ephemeral = true + if !issue.NoHistory { + issue.Ephemeral = true + } if err := s.withConn(ctx, true, func(tx *sql.Tx) error { bc, err := issueops.NewBatchContext(ctx, tx, opts) if err != nil { diff --git a/internal/storage/issueops/create.go b/internal/storage/issueops/create.go index 701005137a..f87eaa5e1b 100644 --- a/internal/storage/issueops/create.go +++ b/internal/storage/issueops/create.go @@ -193,10 +193,12 @@ func ParseHierarchicalID(id string) (parentID string, childNum int, ok bool) { return parentID, num, true } -// AllEphemeral returns true if every issue in the slice is ephemeral. -func AllEphemeral(issues []*types.Issue) bool { +// AllWisps returns true if every issue in the slice should be routed to the +// wisps table (i.e., is ephemeral or no-history). Used to gate the fast path +// that skips Dolt versioning in batch creates. +func AllWisps(issues []*types.Issue) bool { for _, issue := range issues { - if !issue.Ephemeral { + if !issue.Ephemeral && !issue.NoHistory { return false } } diff --git a/internal/storage/issueops/helpers.go b/internal/storage/issueops/helpers.go index 46b5f4a796..4f865bf5b4 100644 --- a/internal/storage/issueops/helpers.go +++ b/internal/storage/issueops/helpers.go @@ -21,9 +21,9 @@ import ( ) // IsWisp returns true if the issue should be routed to the wisps table. -// Matches the DoltStore check: issue.Ephemeral || ID contains "-wisp-". +// Matches the DoltStore check: issue.Ephemeral || issue.NoHistory || ID contains "-wisp-". func IsWisp(issue *types.Issue) bool { - return issue.Ephemeral || strings.Contains(issue.ID, "-wisp-") + return issue.Ephemeral || issue.NoHistory || strings.Contains(issue.ID, "-wisp-") } // TableRouting returns the issue and event table names for an issue, @@ -46,7 +46,7 @@ func InsertIssueIntoTable(ctx context.Context, tx *sql.Tx, table string, issue * status, priority, issue_type, assignee, estimated_minutes, created_at, created_by, owner, updated_at, closed_at, external_ref, spec_id, compaction_level, compacted_at, compacted_at_commit, original_size, - sender, ephemeral, wisp_type, pinned, is_template, crystallizes, + sender, ephemeral, no_history, wisp_type, pinned, is_template, crystallizes, mol_type, work_type, quality_score, source_system, source_repo, close_reason, event_kind, actor, target, payload, await_type, await_id, timeout_ns, waiters, @@ -57,7 +57,7 @@ func InsertIssueIntoTable(ctx context.Context, tx *sql.Tx, table string, issue * ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -87,7 +87,7 @@ func InsertIssueIntoTable(ctx context.Context, tx *sql.Tx, table string, issue * issue.Status, issue.Priority, issue.IssueType, NullString(issue.Assignee), NullInt(issue.EstimatedMinutes), issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt, issue.ClosedAt, NullStringPtr(issue.ExternalRef), issue.SpecID, issue.CompactionLevel, issue.CompactedAt, NullStringPtr(issue.CompactedAtCommit), NullIntVal(issue.OriginalSize), - issue.Sender, issue.Ephemeral, issue.WispType, issue.Pinned, issue.IsTemplate, issue.Crystallizes, + issue.Sender, issue.Ephemeral, issue.NoHistory, issue.WispType, issue.Pinned, issue.IsTemplate, issue.Crystallizes, issue.MolType, issue.WorkType, issue.QualityScore, issue.SourceSystem, issue.SourceRepo, issue.CloseReason, issue.EventKind, issue.Actor, issue.Target, issue.Payload, issue.AwaitType, issue.AwaitID, issue.Timeout.Nanoseconds(), FormatJSONStringArray(issue.Waiters), diff --git a/internal/types/types.go b/internal/types/types.go index 2bd6ac6fb7..c914bdd29f 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -73,9 +73,10 @@ type Issue struct { Comments []*Comment `json:"comments,omitempty"` // ===== Messaging Fields (inter-agent communication) ===== - Sender string `json:"sender,omitempty"` // Who sent this (for messages) - Ephemeral bool `json:"ephemeral,omitempty"` // If true, not synced via git - WispType WispType `json:"wisp_type,omitempty"` // Classification for TTL-based compaction (gt-9br) + Sender string `json:"sender,omitempty"` // Who sent this (for messages) + Ephemeral bool `json:"ephemeral,omitempty"` // If true, not synced via git + NoHistory bool `json:"no_history,omitempty"` // If true, stored in wisps table but NOT GC-eligible + WispType WispType `json:"wisp_type,omitempty"` // Classification for TTL-based compaction (gt-9br) // NOTE: RepliesTo, RelatesTo, DuplicateOf, SupersededBy moved to dependencies table // per Decision 004 (Edge Schema Consolidation). Use dependency API instead. @@ -309,6 +310,10 @@ func (i *Issue) ValidateWithCustom(customStatuses, customTypes []string) error { return fmt.Errorf("metadata must be valid JSON") } } + // Ephemeral and NoHistory are mutually exclusive (GH#2619) + if i.Ephemeral && i.NoHistory { + return fmt.Errorf("ephemeral and no_history are mutually exclusive") + } return nil } diff --git a/internal/types/types_test.go b/internal/types/types_test.go index 321308e422..b95f24048d 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -171,6 +171,44 @@ func TestIssueValidation(t *testing.T) { }, wantErr: false, }, + { + name: "ephemeral and no_history both set", + issue: Issue{ + ID: "test-1", + Title: "Test", + Status: StatusOpen, + Priority: 2, + IssueType: TypeFeature, + Ephemeral: true, + NoHistory: true, + }, + wantErr: true, + errMsg: "ephemeral and no_history are mutually exclusive", + }, + { + name: "ephemeral without no_history", + issue: Issue{ + ID: "test-1", + Title: "Test", + Status: StatusOpen, + Priority: 2, + IssueType: TypeFeature, + Ephemeral: true, + }, + wantErr: false, + }, + { + name: "no_history without ephemeral", + issue: Issue{ + ID: "test-1", + Title: "Test", + Status: StatusOpen, + Priority: 2, + IssueType: TypeFeature, + NoHistory: true, + }, + wantErr: false, + }, } for _, tt := range tests {