fix(beads): prevent routes.jsonl corruption and add doctor check for rig-level routes.jsonl (#377)
* fix(beads): prevent routes.jsonl corruption from bd auto-export When issues.jsonl doesn't exist, bd's auto-export mechanism writes issue data to routes.jsonl, corrupting the routing configuration. Changes: - install.go: Create issues.jsonl before routes.jsonl at town level - manager.go: Create issues.jsonl in rig beads; don't create routes.jsonl (rig-level routes.jsonl breaks bd's walk-up routing to town routes) - Add integration tests for routes.jsonl corruption prevention Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(doctor): add check to detect and fix rig-level routes.jsonl Add RigRoutesJSONLCheck to detect routes.jsonl files in rig .beads directories. These files break bd's walk-up routing to town-level routes.jsonl, causing cross-rig routing failures. The fix unconditionally deletes rig-level routes.jsonl files since bd will auto-export to issues.jsonl on next run. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(rig): add verification that routes.jsonl does NOT exist in rig .beads Add explicit test assertion and detailed comment explaining why rig-level routes.jsonl files must not exist (breaks bd walk-up routing to town routes). Also verify that issues.jsonl DOES exist (prevents bd auto-export corruption). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(doctor): ensure town root route exists in routes.jsonl The RoutesCheck now detects and fixes missing town root routes (hq- -> .). This can happen when routes.jsonl is corrupted or was created without the town route during initialization. Changes: - Detect missing hq- route in Run() - Add hq- route in Fix() when missing - Handle case where routes.jsonl is corrupted (regenerate with town route) - Add comprehensive unit tests for route detection and fixing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(beads): fix routing integration test for routes.jsonl corruption The TestBeadsRoutingFromTownRoot test was failing because bd's auto-export mechanism writes issue data to routes.jsonl when issues.jsonl doesn't exist. This corrupts the routing configuration. Fix: Create empty issues.jsonl after bd init to prevent corruption. This mirrors what gt install does to prevent the same bug. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: julianknutsen <julianknutsen@users.noreply.github> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,14 @@ func initBeadsDBWithPrefix(t *testing.T, dir, prefix string) {
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init failed in %s: %v\n%s", dir, err, output)
|
||||
}
|
||||
|
||||
// Create empty issues.jsonl to prevent bd auto-export from corrupting routes.jsonl.
|
||||
// Without this, bd create writes issue data to routes.jsonl (the first .jsonl file
|
||||
// it finds), corrupting the routing configuration. This mirrors what gt install does.
|
||||
issuesPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
||||
if err := os.WriteFile(issuesPath, []byte(""), 0644); err != nil {
|
||||
t.Fatalf("create issues.jsonl in %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestIssue(t *testing.T, dir, title string) *beads.Issue {
|
||||
|
||||
@@ -132,6 +132,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d.Register(doctor.NewPrefixConflictCheck())
|
||||
d.Register(doctor.NewPrefixMismatchCheck())
|
||||
d.Register(doctor.NewRoutesCheck())
|
||||
d.Register(doctor.NewRigRoutesJSONLCheck())
|
||||
d.Register(doctor.NewOrphanSessionCheck())
|
||||
d.Register(doctor.NewOrphanProcessCheck())
|
||||
d.Register(doctor.NewWispGCCheck())
|
||||
|
||||
@@ -378,6 +378,17 @@ func initTownBeads(townPath string) error {
|
||||
fmt.Printf(" %s Could not verify repo fingerprint: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Ensure issues.jsonl exists BEFORE creating routes.jsonl.
|
||||
// bd init creates beads.db but not issues.jsonl in SQLite mode.
|
||||
// If routes.jsonl is created first, bd's auto-export will write issues to routes.jsonl,
|
||||
// corrupting it. Creating an empty issues.jsonl prevents this.
|
||||
issuesJSONL := filepath.Join(townPath, ".beads", "issues.jsonl")
|
||||
if _, err := os.Stat(issuesJSONL); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(issuesJSONL, []byte{}, 0644); err != nil {
|
||||
fmt.Printf(" %s Could not create issues.jsonl: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure routes.jsonl has an explicit town-level mapping for hq-* beads.
|
||||
// This keeps hq-* operations stable even when invoked from rig worktrees.
|
||||
if err := beads.AppendRoute(townPath, beads.Route{Prefix: "hq-", Path: "."}); err != nil {
|
||||
|
||||
@@ -341,6 +341,49 @@ func TestRigAddInitializesBeads(t *testing.T) {
|
||||
t.Errorf("config.yaml doesn't contain expected prefix, got: %s", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IMPORTANT: Verify routes.jsonl does NOT exist in the rig's .beads directory
|
||||
// =========================================================================
|
||||
//
|
||||
// WHY WE DON'T CREATE routes.jsonl IN RIG DIRECTORIES:
|
||||
//
|
||||
// 1. BD'S WALK-UP ROUTING MECHANISM:
|
||||
// When bd needs to find routing configuration, it walks up the directory
|
||||
// tree looking for a .beads directory with routes.jsonl. It stops at the
|
||||
// first routes.jsonl it finds. If a rig has its own routes.jsonl, bd will
|
||||
// use that and NEVER reach the town-level routes.jsonl, breaking cross-rig
|
||||
// routing entirely.
|
||||
//
|
||||
// 2. TOWN-LEVEL ROUTING IS THE SOURCE OF TRUTH:
|
||||
// All routing configuration belongs in the town's .beads/routes.jsonl.
|
||||
// This single file contains prefix->path mappings for ALL rigs, enabling
|
||||
// bd to route issue IDs like "tr-123" to the correct rig directory.
|
||||
//
|
||||
// 3. HISTORICAL BUG - BD AUTO-EXPORT CORRUPTION:
|
||||
// There was a bug where bd's auto-export feature would write issue data
|
||||
// to routes.jsonl if issues.jsonl didn't exist. This corrupted routing
|
||||
// config with issue JSON objects. We now create empty issues.jsonl files
|
||||
// proactively to prevent this, but we also verify routes.jsonl doesn't
|
||||
// exist as a defense-in-depth measure.
|
||||
//
|
||||
// 4. DOCTOR CHECK EXISTS:
|
||||
// The "rig-routes-jsonl" doctor check detects and can fix (delete) any
|
||||
// routes.jsonl files that appear in rig .beads directories.
|
||||
//
|
||||
// If you're modifying rig creation and thinking about adding routes.jsonl
|
||||
// to the rig's .beads directory - DON'T. It will break cross-rig routing.
|
||||
// =========================================================================
|
||||
rigRoutesPath := filepath.Join(beadsDir, "routes.jsonl")
|
||||
if _, err := os.Stat(rigRoutesPath); err == nil {
|
||||
t.Errorf("routes.jsonl should NOT exist in rig .beads directory (breaks bd walk-up routing)")
|
||||
}
|
||||
|
||||
// Verify issues.jsonl DOES exist (prevents bd auto-export corruption)
|
||||
rigIssuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if _, err := os.Stat(rigIssuesPath); err != nil {
|
||||
t.Errorf("issues.jsonl should exist in rig .beads directory (prevents auto-export corruption): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRigAddUpdatesRoutes verifies that routes.jsonl is updated
|
||||
|
||||
174
internal/cmd/routes_jsonl_corruption_test.go
Normal file
174
internal/cmd/routes_jsonl_corruption_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
//go:build integration
|
||||
|
||||
// Package cmd contains integration tests for routes.jsonl corruption prevention.
|
||||
//
|
||||
// Run with: go test -tags=integration ./internal/cmd -run TestRoutesJSONLCorruption -v
|
||||
//
|
||||
// Bug: bd's auto-export writes issue data to routes.jsonl when issues.jsonl doesn't exist,
|
||||
// corrupting the routing configuration.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRoutesJSONLCorruption tests that routes.jsonl is not corrupted by bd auto-export.
|
||||
func TestRoutesJSONLCorruption(t *testing.T) {
|
||||
// Skip if bd is not available
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
t.Skip("bd not installed, skipping test")
|
||||
}
|
||||
|
||||
t.Run("TownLevelRoutesNotCorrupted", func(t *testing.T) {
|
||||
// Test that gt install creates issues.jsonl before routes.jsonl
|
||||
// so that bd auto-export doesn't corrupt routes.jsonl
|
||||
tmpDir := t.TempDir()
|
||||
townRoot := filepath.Join(tmpDir, "test-town")
|
||||
|
||||
gtBinary := buildGT(t)
|
||||
|
||||
// Install town
|
||||
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "test-town")
|
||||
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Verify issues.jsonl exists
|
||||
issuesPath := filepath.Join(townRoot, ".beads", "issues.jsonl")
|
||||
if _, err := os.Stat(issuesPath); os.IsNotExist(err) {
|
||||
t.Error("issues.jsonl should exist after gt install")
|
||||
}
|
||||
|
||||
// Verify routes.jsonl exists and has valid content
|
||||
routesPath := filepath.Join(townRoot, ".beads", "routes.jsonl")
|
||||
routesContent, err := os.ReadFile(routesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read routes.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// routes.jsonl should contain routing config, not issue data
|
||||
if !strings.Contains(string(routesContent), `"prefix"`) {
|
||||
t.Errorf("routes.jsonl should contain prefix routing, got: %s", routesContent)
|
||||
}
|
||||
if strings.Contains(string(routesContent), `"title"`) {
|
||||
t.Errorf("routes.jsonl should NOT contain issue data (title field), got: %s", routesContent)
|
||||
}
|
||||
|
||||
// Create an issue and verify routes.jsonl is still valid
|
||||
cmd = exec.Command("bd", "--no-daemon", "-q", "create", "--type", "task", "--title", "test issue")
|
||||
cmd.Dir = townRoot
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd create failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Re-read routes.jsonl - it should NOT be corrupted
|
||||
routesContent, err = os.ReadFile(routesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read routes.jsonl after create: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(string(routesContent), `"title"`) {
|
||||
t.Errorf("routes.jsonl was corrupted with issue data after bd create: %s", routesContent)
|
||||
}
|
||||
if !strings.Contains(string(routesContent), `"prefix"`) {
|
||||
t.Errorf("routes.jsonl lost its routing config: %s", routesContent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RigLevelNoRoutesJSONL", func(t *testing.T) {
|
||||
// Test that gt rig add does NOT create routes.jsonl in rig beads
|
||||
// (rig-level routes.jsonl breaks bd's walk-up routing to town routes)
|
||||
tmpDir := t.TempDir()
|
||||
townRoot := filepath.Join(tmpDir, "test-town")
|
||||
|
||||
gtBinary := buildGT(t)
|
||||
|
||||
// Create a test repo (createTestGitRepo returns the path)
|
||||
repoDir := createTestGitRepo(t, "test-repo")
|
||||
|
||||
// Install town
|
||||
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "test-town")
|
||||
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Add a rig
|
||||
cmd = exec.Command(gtBinary, "rig", "add", "testrig", repoDir)
|
||||
cmd.Dir = townRoot
|
||||
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("gt rig add failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Verify rig beads directory exists
|
||||
rigBeadsDir := filepath.Join(townRoot, "testrig", ".beads")
|
||||
if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) {
|
||||
t.Fatal("rig .beads directory should exist")
|
||||
}
|
||||
|
||||
// Verify issues.jsonl exists in rig beads
|
||||
rigIssuesPath := filepath.Join(rigBeadsDir, "issues.jsonl")
|
||||
if _, err := os.Stat(rigIssuesPath); os.IsNotExist(err) {
|
||||
t.Error("issues.jsonl should exist in rig beads")
|
||||
}
|
||||
|
||||
// Verify routes.jsonl does NOT exist in rig beads
|
||||
rigRoutesPath := filepath.Join(rigBeadsDir, "routes.jsonl")
|
||||
if _, err := os.Stat(rigRoutesPath); err == nil {
|
||||
t.Error("routes.jsonl should NOT exist in rig beads (breaks walk-up routing)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CorruptionReproduction", func(t *testing.T) {
|
||||
// This test reproduces the bug: if issues.jsonl doesn't exist,
|
||||
// bd auto-export writes to routes.jsonl
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
// Initialize beads
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Remove issues.jsonl if it exists (to simulate the bug condition)
|
||||
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
os.Remove(issuesPath)
|
||||
|
||||
// Create routes.jsonl with valid routing config
|
||||
routesPath := filepath.Join(beadsDir, "routes.jsonl")
|
||||
routesContent := `{"prefix":"test-","path":"."}`
|
||||
if err := os.WriteFile(routesPath, []byte(routesContent+"\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write routes.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Create an issue - this triggers auto-export
|
||||
cmd = exec.Command("bd", "--no-daemon", "-q", "create", "--type", "task", "--title", "bug reproduction")
|
||||
cmd.Dir = tmpDir
|
||||
cmd.CombinedOutput() // Ignore error - we're testing the corruption
|
||||
|
||||
// Check if routes.jsonl was corrupted
|
||||
newRoutesContent, err := os.ReadFile(routesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read routes.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// If routes.jsonl contains "title", it was corrupted with issue data
|
||||
if strings.Contains(string(newRoutesContent), `"title"`) {
|
||||
t.Log("BUG REPRODUCED: routes.jsonl was corrupted with issue data")
|
||||
t.Log("Content:", string(newRoutesContent))
|
||||
// This is expected behavior WITHOUT the fix
|
||||
// The test passes if the fix prevents this
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Note: createTestGitRepo is defined in rig_integration_test.go
|
||||
Reference in New Issue
Block a user