Files
beads/cmd/bd/doctor/fix/maintenance.go
emma 4a0f4abc70 feat(doctor): add patrol pollution detection and fix
Add CheckPatrolPollution to detect stale patrol beads:
- Patrol digests matching 'Digest: mol-*-patrol'
- Session ended beads matching 'Session ended: *'

Includes auto-fix via 'bd doctor --fix' to clean up pollution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:15:37 -08:00

274 lines
6.9 KiB
Go

package fix
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// DefaultCleanupAgeDays is the default age threshold for cleanup
const DefaultCleanupAgeDays = 30
// CleanupResult contains the results of a cleanup operation
type CleanupResult struct {
DeletedCount int
TombstoneCount int
SkippedPinned int
}
// StaleClosedIssues converts stale closed issues to tombstones.
// This is the fix handler for the "Stale Closed Issues" doctor check.
func StaleClosedIssues(path string) error {
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
// Get database path
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
fmt.Println(" No database found, nothing to clean up")
return nil
}
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer func() { _ = store.Close() }()
// Find closed issues older than threshold
cutoff := time.Now().AddDate(0, 0, -DefaultCleanupAgeDays)
statusClosed := types.StatusClosed
filter := types.IssueFilter{
Status: &statusClosed,
ClosedBefore: &cutoff,
}
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
return fmt.Errorf("failed to query issues: %w", err)
}
// Filter out pinned issues and delete the rest
var deleted, skipped int
for _, issue := range issues {
if issue.Pinned {
skipped++
continue
}
if err := store.DeleteIssue(ctx, issue.ID); err != nil {
fmt.Printf(" Warning: failed to delete %s: %v\n", issue.ID, err)
continue
}
deleted++
}
if deleted == 0 && skipped == 0 {
fmt.Println(" No stale closed issues to clean up")
} else {
if deleted > 0 {
fmt.Printf(" Cleaned up %d stale closed issue(s)\n", deleted)
}
if skipped > 0 {
fmt.Printf(" Skipped %d pinned issue(s)\n", skipped)
}
}
return nil
}
// ExpiredTombstones prunes expired tombstones from issues.jsonl.
// This is the fix handler for the "Expired Tombstones" doctor check.
func ExpiredTombstones(path string) error {
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
fmt.Println(" No JSONL file found, nothing to prune")
return nil
}
// Read all issues
file, err := os.Open(jsonlPath) // #nosec G304 - path constructed safely
if err != nil {
return fmt.Errorf("failed to open issues.jsonl: %w", err)
}
var allIssues []*types.Issue
decoder := json.NewDecoder(file)
for {
var issue types.Issue
if err := decoder.Decode(&issue); err != nil {
break
}
issue.SetDefaults()
allIssues = append(allIssues, &issue)
}
_ = file.Close()
ttl := types.DefaultTombstoneTTL
// Filter out expired tombstones
var kept []*types.Issue
var prunedCount int
for _, issue := range allIssues {
if issue.IsExpired(ttl) {
prunedCount++
} else {
kept = append(kept, issue)
}
}
if prunedCount == 0 {
fmt.Println(" No expired tombstones to prune")
return nil
}
// Write back the pruned file atomically
tempFile, err := os.CreateTemp(beadsDir, "issues.jsonl.prune.*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tempPath := tempFile.Name()
encoder := json.NewEncoder(tempFile)
for _, issue := range kept {
if err := encoder.Encode(issue); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempPath)
return fmt.Errorf("failed to write issue %s: %w", issue.ID, err)
}
}
_ = tempFile.Close()
// Atomically replace
if err := os.Rename(tempPath, jsonlPath); err != nil {
_ = os.Remove(tempPath)
return fmt.Errorf("failed to replace issues.jsonl: %w", err)
}
ttlDays := int(ttl.Hours() / 24)
fmt.Printf(" Pruned %d expired tombstone(s) (older than %d days)\n", prunedCount, ttlDays)
return nil
}
// PatrolPollution deletes patrol digest and session ended beads that pollute the database.
// This is the fix handler for the "Patrol Pollution" doctor check.
//
// It removes beads matching:
// - Patrol digests: titles matching "Digest: mol-*-patrol"
// - Session ended beads: titles matching "Session ended: *"
//
// After deletion, runs compact --purge-tombstones equivalent to clean up.
func PatrolPollution(path string) error {
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
fmt.Println(" No JSONL file found, nothing to clean up")
return nil
}
// Get database path
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer func() { _ = store.Close() }()
// Get all issues and identify pollution
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
return fmt.Errorf("failed to query issues: %w", err)
}
var patrolDigestCount, sessionBeadCount int
var toDelete []string
for _, issue := range issues {
// Skip tombstones
if issue.DeletedAt != nil {
continue
}
title := issue.Title
// Check for patrol digest pattern: "Digest: mol-*-patrol"
if strings.HasPrefix(title, "Digest: mol-") && strings.HasSuffix(title, "-patrol") {
patrolDigestCount++
toDelete = append(toDelete, issue.ID)
continue
}
// Check for session ended pattern: "Session ended: *"
if strings.HasPrefix(title, "Session ended:") {
sessionBeadCount++
toDelete = append(toDelete, issue.ID)
}
}
if len(toDelete) == 0 {
fmt.Println(" No patrol pollution beads to delete")
return nil
}
// Delete all pollution beads
var deleted int
for _, id := range toDelete {
if err := store.DeleteIssue(ctx, id); err != nil {
fmt.Printf(" Warning: failed to delete %s: %v\n", id, err)
continue
}
deleted++
}
// Report results
if patrolDigestCount > 0 {
fmt.Printf(" Deleted %d patrol digest bead(s)\n", patrolDigestCount)
}
if sessionBeadCount > 0 {
fmt.Printf(" Deleted %d session ended bead(s)\n", sessionBeadCount)
}
fmt.Printf(" Total: %d pollution bead(s) removed\n", deleted)
// Suggest running compact to purge tombstones
fmt.Println(" 💡 Run 'bd compact --purge-tombstones' to reclaim space")
return nil
}