From 887c958567de814b927594f21538b3473b0937a5 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 26 Nov 2025 23:25:32 -0800 Subject: [PATCH] fix(autoimport): prevent export to wrong JSONL file (bd-tqo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FindJSONLInDir helper that correctly prefers issues.jsonl over other .jsonl files. Previously, glob patterns could return deletions.jsonl or merge artifacts (beads.base.jsonl, etc.) first alphabetically, causing issue data to be written to the wrong file. This fixes the root cause of deletions.jsonl corruption where full issue objects were written instead of deletion records, leading to all issues being purged during sync. Changes: - Add FindJSONLInDir() in internal/autoimport with proper file selection - Update AutoImportIfNewer() to use FindJSONLInDir - Update CheckStaleness() to use FindJSONLInDir - Update triggerExport() in RPC server to use FindJSONLInDir - Add comprehensive tests for FindJSONLInDir 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/autoimport/autoimport.go | 67 +++++++++++++------- internal/autoimport/autoimport_test.go | 76 +++++++++++++++++++++++ internal/rpc/server_export_import_auto.go | 10 +-- 3 files changed, 124 insertions(+), 29 deletions(-) diff --git a/internal/autoimport/autoimport.go b/internal/autoimport/autoimport.go index 74d140e2..f82bf6bf 100644 --- a/internal/autoimport/autoimport.go +++ b/internal/autoimport/autoimport.go @@ -67,14 +67,7 @@ func AutoImportIfNewer(ctx context.Context, store storage.Storage, dbPath string // Find JSONL using database directory (same logic as beads.FindJSONLPath) dbDir := filepath.Dir(dbPath) - pattern := filepath.Join(dbDir, "*.jsonl") - matches, err := filepath.Glob(pattern) - var jsonlPath string - if err == nil && len(matches) > 0 { - jsonlPath = matches[0] - } else { - jsonlPath = filepath.Join(dbDir, "issues.jsonl") - } + jsonlPath := FindJSONLInDir(dbDir) if jsonlPath == "" { notify.Debugf("auto-import skipped, JSONL not found") return nil @@ -287,19 +280,7 @@ func CheckStaleness(ctx context.Context, store storage.Storage, dbPath string) ( // Find JSONL using database directory dbDir := filepath.Dir(dbPath) - pattern := filepath.Join(dbDir, "*.jsonl") - matches, err := filepath.Glob(pattern) - if err != nil { - // Glob failed - this is abnormal - return false, fmt.Errorf("failed to find JSONL file: %w", err) - } - - var jsonlPath string - if len(matches) > 0 { - jsonlPath = matches[0] - } else { - jsonlPath = filepath.Join(dbDir, "issues.jsonl") - } + jsonlPath := FindJSONLInDir(dbDir) stat, err := os.Stat(jsonlPath) if err != nil { @@ -313,3 +294,47 @@ func CheckStaleness(ctx context.Context, store storage.Storage, dbPath string) ( return stat.ModTime().After(lastImportTime), nil } + +// FindJSONLInDir finds the JSONL file in the given directory. +// It prefers issues.jsonl over other .jsonl files to prevent accidentally +// reading/writing to deletions.jsonl or merge artifacts (bd-tqo fix). +// Returns empty string if no suitable JSONL file is found. +func FindJSONLInDir(dbDir string) string { + pattern := filepath.Join(dbDir, "*.jsonl") + matches, err := filepath.Glob(pattern) + if err != nil || len(matches) == 0 { + // Default to issues.jsonl if glob fails or no matches + return filepath.Join(dbDir, "issues.jsonl") + } + + // Prefer issues.jsonl over other .jsonl files (bd-tqo fix) + // This prevents accidentally using deletions.jsonl or merge artifacts + for _, match := range matches { + if filepath.Base(match) == "issues.jsonl" { + return match + } + } + + // Fall back to beads.jsonl for legacy support + for _, match := range matches { + if filepath.Base(match) == "beads.jsonl" { + return match + } + } + + // Last resort: use first match (but skip deletions.jsonl and merge artifacts) + for _, match := range matches { + base := filepath.Base(match) + // Skip deletions manifest and merge artifacts + if base == "deletions.jsonl" || + base == "beads.base.jsonl" || + base == "beads.left.jsonl" || + base == "beads.right.jsonl" { + continue + } + return match + } + + // If only deletions/merge files exist, default to issues.jsonl + return filepath.Join(dbDir, "issues.jsonl") +} diff --git a/internal/autoimport/autoimport_test.go b/internal/autoimport/autoimport_test.go index 70d02964..d1f42f88 100644 --- a/internal/autoimport/autoimport_test.go +++ b/internal/autoimport/autoimport_test.go @@ -517,3 +517,79 @@ func TestStderrNotifier(t *testing.T) { notify.Infof("test info") }) } + +// TestFindJSONLInDir tests that FindJSONLInDir correctly prefers issues.jsonl +// and avoids deletions.jsonl and merge artifacts (bd-tqo fix) +func TestFindJSONLInDir(t *testing.T) { + tests := []struct { + name string + files []string + expected string + }{ + { + name: "only issues.jsonl", + files: []string{"issues.jsonl"}, + expected: "issues.jsonl", + }, + { + name: "issues.jsonl and deletions.jsonl - prefers issues", + files: []string{"deletions.jsonl", "issues.jsonl"}, + expected: "issues.jsonl", + }, + { + name: "issues.jsonl with merge artifacts - prefers issues", + files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl", "issues.jsonl"}, + expected: "issues.jsonl", + }, + { + name: "beads.jsonl as legacy fallback", + files: []string{"beads.jsonl"}, + expected: "beads.jsonl", + }, + { + name: "issues.jsonl preferred over beads.jsonl", + files: []string{"beads.jsonl", "issues.jsonl"}, + expected: "issues.jsonl", + }, + { + name: "only deletions.jsonl - returns default issues.jsonl", + files: []string{"deletions.jsonl"}, + expected: "issues.jsonl", + }, + { + name: "only merge artifacts - returns default issues.jsonl", + files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl"}, + expected: "issues.jsonl", + }, + { + name: "no files - returns default issues.jsonl", + files: []string{}, + expected: "issues.jsonl", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "bd-findjsonl-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create test files + for _, file := range tt.files { + path := filepath.Join(tmpDir, file) + if err := os.WriteFile(path, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + } + + result := FindJSONLInDir(tmpDir) + got := filepath.Base(result) + + if got != tt.expected { + t.Errorf("FindJSONLInDir() = %q, want %q", got, tt.expected) + } + }) + } +} diff --git a/internal/rpc/server_export_import_auto.go b/internal/rpc/server_export_import_auto.go index c859937c..1c7018d8 100644 --- a/internal/rpc/server_export_import_auto.go +++ b/internal/rpc/server_export_import_auto.go @@ -441,15 +441,9 @@ func hasUncommittedBeadsFiles(workspacePath string) bool { // CRITICAL: Must populate all issue data (deps, labels, comments) to prevent data loss func (s *Server) triggerExport(ctx context.Context, store storage.Storage, dbPath string) error { // Find JSONL path using database directory + // Use FindJSONLInDir to prefer issues.jsonl over other .jsonl files (bd-tqo fix) dbDir := filepath.Dir(dbPath) - pattern := filepath.Join(dbDir, "*.jsonl") - matches, err := filepath.Glob(pattern) - var jsonlPath string - if err == nil && len(matches) > 0 { - jsonlPath = matches[0] - } else { - jsonlPath = filepath.Join(dbDir, "issues.jsonl") - } + jsonlPath := autoimport.FindJSONLInDir(dbDir) // Get all issues from storage sqliteStore, ok := store.(*sqlite.SQLiteStorage)