diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index e5c0c35f..7cbe32bd 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -38,7 +38,7 @@ {"id":"bd-9li4","content_hash":"7ae7b885e82a2de333584c01f690dbc3ecb924603f18e316f5c91cc44e2256f8","title":"Create Docker image for Agent Mail","description":"Containerize Agent Mail server for easy deployment.\n\nAcceptance Criteria:\n- Dockerfile with Python 3.14\n- Health check endpoint\n- Volume mount for storage\n- Environment variable configuration\n- Multi-arch builds (amd64, arm64)\n\nFile: deployment/agent-mail/Dockerfile","status":"open","priority":3,"issue_type":"task","created_at":"2025-11-07T22:43:43.231964-08:00","updated_at":"2025-11-07T22:43:43.231964-08:00","source_repo":"."} {"id":"bd-9msn","content_hash":"69ef2ebc5a847eb407c37e9039391d8ebc761a4cee3b60537de4f5a12011bec3","title":"Add monitoring and alerting","description":"Observability for production Agent Mail server.\n\nAcceptance Criteria:\n- Health check endpoint (/health)\n- Prometheus metrics export\n- Grafana dashboard\n- Alerts for server downtime\n- Alerts for high error rate\n- Log aggregation config\n\nFile: deployment/agent-mail/monitoring/","status":"open","priority":3,"issue_type":"task","created_at":"2025-11-07T22:43:43.354117-08:00","updated_at":"2025-11-07T22:43:43.354117-08:00","source_repo":".","dependencies":[{"issue_id":"bd-9msn","depends_on_id":"bd-z3s3","type":"blocks","created_at":"2025-11-07T23:04:28.050074-08:00","created_by":"daemon"}]} {"id":"bd-ar2","content_hash":"478a13448ad54ed08285cb66cd57b1bc410b8d80a4d6a27d95fd2405fa46f067","title":"Code review follow-up for bd-dvd and bd-ymj fixes","description":"Track improvements and issues identified during code review of parent resurrection (bd-dvd) and export metadata (bd-ymj) bug fixes.\n\n## Context\nCode review identified several areas for improvement:\n- Code duplication in metadata updates\n- Missing multi-repo support\n- Test coverage gaps\n- Potential race conditions\n\n## Related Issues\nOriginal bugs fixed: bd-dvd, bd-ymj\n\n## Goals\n- Eliminate code duplication\n- Add multi-repo support where needed\n- Improve test coverage\n- Address edge cases","status":"open","priority":2,"issue_type":"epic","created_at":"2025-11-21T10:24:05.78635-05:00","updated_at":"2025-11-21T10:24:05.78635-05:00","source_repo":"."} -{"id":"bd-ar2.1","content_hash":"ae7a810429b3a3b9f99bef19bf6d7dec0c2ef9288ca2ba9d0344a1460657bcb6","title":"Extract duplicated metadata update code in daemon_sync.go","description":"## Problem\nThe same 22-line metadata update block appears identically in both:\n- createExportFunc (lines 309-328)\n- createSyncFunc (lines 520-539)\n\nThis violates DRY principle and makes maintenance harder.\n\n## Solution\nExtract to helper function:\n\n```go\n// updateExportMetadata updates last_import_hash and related metadata after a successful export.\n// This prevents \"JSONL content has changed since last import\" errors on subsequent exports (bd-ymj fix).\nfunc updateExportMetadata(ctx context.Context, store storage.Storage, jsonlPath string, log daemonLogger) {\n currentHash, err := computeJSONLHash(jsonlPath)\n if err != nil {\n log.log(\"Warning: failed to compute JSONL hash for metadata update: %v\", err)\n return\n }\n \n if err := store.SetMetadata(ctx, \"last_import_hash\", currentHash); err != nil {\n log.log(\"Warning: failed to update last_import_hash: %v\", err)\n }\n \n exportTime := time.Now().Format(time.RFC3339)\n if err := store.SetMetadata(ctx, \"last_import_time\", exportTime); err != nil {\n log.log(\"Warning: failed to update last_import_time: %v\", err)\n }\n \n // Store mtime for fast-path optimization\n if jsonlInfo, statErr := os.Stat(jsonlPath); statErr == nil {\n mtimeStr := fmt.Sprintf(\"%d\", jsonlInfo.ModTime().Unix())\n if err := store.SetMetadata(ctx, \"last_import_mtime\", mtimeStr); err != nil {\n log.log(\"Warning: failed to update last_import_mtime: %v\", err)\n }\n }\n}\n```\n\n## Files\n- cmd/bd/daemon_sync.go\n\n## Benefits\n- Easier maintenance\n- Single source of truth\n- Consistent behavior","status":"open","priority":1,"issue_type":"bug","created_at":"2025-11-21T10:24:18.888412-05:00","updated_at":"2025-11-21T10:24:18.888412-05:00","source_repo":".","dependencies":[{"issue_id":"bd-ar2.1","depends_on_id":"bd-ar2","type":"parent-child","created_at":"2025-11-21T10:24:18.889171-05:00","created_by":"daemon"}]} +{"id":"bd-ar2.1","content_hash":"ae7a810429b3a3b9f99bef19bf6d7dec0c2ef9288ca2ba9d0344a1460657bcb6","title":"Extract duplicated metadata update code in daemon_sync.go","description":"## Problem\nThe same 22-line metadata update block appears identically in both:\n- createExportFunc (lines 309-328)\n- createSyncFunc (lines 520-539)\n\nThis violates DRY principle and makes maintenance harder.\n\n## Solution\nExtract to helper function:\n\n```go\n// updateExportMetadata updates last_import_hash and related metadata after a successful export.\n// This prevents \"JSONL content has changed since last import\" errors on subsequent exports (bd-ymj fix).\nfunc updateExportMetadata(ctx context.Context, store storage.Storage, jsonlPath string, log daemonLogger) {\n currentHash, err := computeJSONLHash(jsonlPath)\n if err != nil {\n log.log(\"Warning: failed to compute JSONL hash for metadata update: %v\", err)\n return\n }\n \n if err := store.SetMetadata(ctx, \"last_import_hash\", currentHash); err != nil {\n log.log(\"Warning: failed to update last_import_hash: %v\", err)\n }\n \n exportTime := time.Now().Format(time.RFC3339)\n if err := store.SetMetadata(ctx, \"last_import_time\", exportTime); err != nil {\n log.log(\"Warning: failed to update last_import_time: %v\", err)\n }\n \n // Store mtime for fast-path optimization\n if jsonlInfo, statErr := os.Stat(jsonlPath); statErr == nil {\n mtimeStr := fmt.Sprintf(\"%d\", jsonlInfo.ModTime().Unix())\n if err := store.SetMetadata(ctx, \"last_import_mtime\", mtimeStr); err != nil {\n log.log(\"Warning: failed to update last_import_mtime: %v\", err)\n }\n }\n}\n```\n\n## Files\n- cmd/bd/daemon_sync.go\n\n## Benefits\n- Easier maintenance\n- Single source of truth\n- Consistent behavior","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-11-21T10:24:18.888412-05:00","updated_at":"2025-11-21T10:47:24.430037-05:00","closed_at":"2025-11-21T10:47:24.430037-05:00","source_repo":".","dependencies":[{"issue_id":"bd-ar2.1","depends_on_id":"bd-ar2","type":"parent-child","created_at":"2025-11-21T10:24:18.889171-05:00","created_by":"daemon"}]} {"id":"bd-ar2.2","content_hash":"04df0425145cf1eac1ca93e204ea3fda5c9724e413ca0e2f68d212b853f1ca8e","title":"Add multi-repo support to export metadata updates","description":"## Problem\nThe bd-ymj fix only updates metadata for the main jsonlPath, but the codebase supports multi-repo mode where exports write to multiple JSONL files.\n\nOther daemon_sync operations handle multi-repo correctly:\n- Lines 509-518: Snapshots captured for all multi-repo paths\n- Lines 578-587: Deletions applied for all multi-repo paths\n\nBut metadata updates are missing!\n\n## Investigation Needed\nMetadata is stored globally in database (`last_import_hash`, `last_import_mtime`), but multi-repo has per-repo JSONL files. Current schema may not support tracking multiple JSONL files.\n\nOptions:\n1. Store metadata per-repo (new schema)\n2. Store combined hash of all repos\n3. Document that multi-repo doesn't need this metadata (why?)\n\n## Solution (if needed)\n```go\n// After export\nif multiRepoPaths := getMultiRepoJSONLPaths(); multiRepoPaths != nil {\n // Multi-repo mode: update metadata for each JSONL\n for _, path := range multiRepoPaths {\n updateExportMetadata(exportCtx, store, path, log)\n }\n} else {\n // Single-repo mode: update metadata for main JSONL\n updateExportMetadata(exportCtx, store, jsonlPath, log)\n}\n```\n\n## Files\n- cmd/bd/daemon_sync.go (createExportFunc, createSyncFunc)\n- Possibly: internal/storage/sqlite/metadata.go (schema changes)\n\n## Related\nDepends on bd-ar2.1 (extract helper function first)","status":"open","priority":1,"issue_type":"bug","created_at":"2025-11-21T10:24:32.482102-05:00","updated_at":"2025-11-21T10:24:32.482102-05:00","source_repo":".","dependencies":[{"issue_id":"bd-ar2.2","depends_on_id":"bd-ar2","type":"parent-child","created_at":"2025-11-21T10:24:32.482559-05:00","created_by":"daemon"}]} {"id":"bd-ar2.3","content_hash":"2591aae57276ceef24ad19fb24c1f7ad142e501c55d4ab1b6d7e2f43bb917119","title":"Fix TestExportUpdatesMetadata to test actual daemon functions","description":"## Problem\nTestExportUpdatesMetadata (daemon_sync_test.go:296) manually replicates the metadata update logic rather than calling the fixed functions (createExportFunc/createSyncFunc).\n\nThis means:\n- Test verifies the CONCEPT works\n- But doesn't verify the IMPLEMENTATION in the actual daemon functions\n- If daemon code is wrong, test still passes\n\n## Current Test Flow\n```go\n// Test does:\nexportToJSONLWithStore() // ← Direct call\n// ... manual metadata update ...\nexportToJSONLWithStore() // ← Direct call again\n```\n\n## Should Be\n```go\n// Test should:\ncreateExportFunc(...)() // ← Call actual daemon function\n// ... verify metadata was updated by the function ...\ncreateExportFunc(...)() // ← Call again, should not fail\n```\n\n## Challenge\ncreateExportFunc returns a closure that's run by the daemon. Testing this requires:\n- Mock logger\n- Proper context setup\n- Git operations might need mocking\n\n## Files\n- cmd/bd/daemon_sync_test.go\n\n## Acceptance Criteria\n- Test calls actual createExportFunc and createSyncFunc\n- Test verifies metadata updated by daemon code, not test code\n- Both functions tested (export and sync)","status":"open","priority":1,"issue_type":"bug","created_at":"2025-11-21T10:24:46.756315-05:00","updated_at":"2025-11-21T10:24:46.756315-05:00","source_repo":".","dependencies":[{"issue_id":"bd-ar2.3","depends_on_id":"bd-ar2","type":"parent-child","created_at":"2025-11-21T10:24:46.757622-05:00","created_by":"daemon"}]} {"id":"bd-ar2.4","content_hash":"474ad0a473f6f825bd721b3091792f306bdc1aac9d88159c19ec3bcbc893459e","title":"Use TryResurrectParentChain instead of TryResurrectParent in GetNextChildID","description":"## Context\nCurrent bd-dvd fix uses TryResurrectParent, which only resurrects the immediate parent. For deeply nested hierarchies, this might not be sufficient.\n\n## Example Scenario\nCreating child with parent \"bd-abc.1.2\" where:\n- bd-abc exists in DB\n- bd-abc.1 was deleted (missing from DB, exists in JSONL)\n- bd-abc.1.2 was deleted (missing from DB, exists in JSONL)\n\nCurrent fix: Only tries to resurrect \"bd-abc.1.2\", fails because its parent \"bd-abc.1\" is missing.\n\n## Solution\nReplace TryResurrectParent with TryResurrectParentChain:\n\n```go\nif count == 0 {\n // Try to resurrect entire parent chain from JSONL history (bd-dvd fix)\n // This handles deeply nested hierarchies where intermediate parents are also missing\n resurrected, err := s.TryResurrectParentChain(ctx, parentID)\n if err != nil {\n return \"\", fmt.Errorf(\"failed to resurrect parent chain for %s: %w\", parentID, err)\n }\n if !resurrected {\n return \"\", fmt.Errorf(\"parent issue %s does not exist and could not be resurrected from JSONL history\", parentID)\n }\n}\n```\n\n## Investigation\n- Check if this scenario actually happens in practice\n- TryResurrectParentChain already exists (resurrection.go:174-209)\n- May be overkill if users always create parents before children\n\n## Files\n- internal/storage/sqlite/hash_ids.go\n\n## Testing\nAdd test case for deeply nested resurrection","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-21T10:25:01.376071-05:00","updated_at":"2025-11-21T10:25:01.376071-05:00","source_repo":".","dependencies":[{"issue_id":"bd-ar2.4","depends_on_id":"bd-ar2","type":"parent-child","created_at":"2025-11-21T10:25:01.376561-05:00","created_by":"daemon"}]} diff --git a/cmd/bd/daemon_sync.go b/cmd/bd/daemon_sync.go index 60450cca..7e61292b 100644 --- a/cmd/bd/daemon_sync.go +++ b/cmd/bd/daemon_sync.go @@ -8,9 +8,11 @@ import ( "os" "path/filepath" "sort" + "strings" "time" "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" @@ -199,6 +201,81 @@ func importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat return err } +// getRepoKeyForPath extracts the stable repo identifier from a JSONL path. +// For single-repo mode, returns empty string (no suffix needed). +// For multi-repo mode, extracts the repo path (e.g., ".", "../frontend"). +// This creates portable metadata keys that work across different machine paths. +func getRepoKeyForPath(jsonlPath string) string { + multiRepo := config.GetMultiRepoConfig() + if multiRepo == nil { + return "" // Single-repo mode + } + + // Normalize the jsonlPath for comparison + // Remove trailing "/.beads/issues.jsonl" to get repo path + const suffix = "/.beads/issues.jsonl" + if strings.HasSuffix(jsonlPath, suffix) { + repoPath := strings.TrimSuffix(jsonlPath, suffix) + + // Try to match against primary repo + primaryPath := multiRepo.Primary + if primaryPath == "" { + primaryPath = "." + } + if repoPath == primaryPath { + return primaryPath + } + + // Try to match against additional repos + for _, additional := range multiRepo.Additional { + if repoPath == additional { + return additional + } + } + } + + // Fallback: return empty string for single-repo mode behavior + return "" +} + +// updateExportMetadata updates last_import_hash and related metadata after a successful export. +// This prevents "JSONL content has changed since last import" errors on subsequent exports (bd-ymj fix). +// In multi-repo mode, keySuffix should be the stable repo identifier (e.g., ".", "../frontend"). +func updateExportMetadata(ctx context.Context, store storage.Storage, jsonlPath string, log daemonLogger, keySuffix string) { + currentHash, err := computeJSONLHash(jsonlPath) + if err != nil { + log.log("Warning: failed to compute JSONL hash for metadata update: %v", err) + return + } + + // Build metadata keys with optional suffix for per-repo tracking + hashKey := "last_import_hash" + timeKey := "last_import_time" + mtimeKey := "last_import_mtime" + if keySuffix != "" { + hashKey += ":" + keySuffix + timeKey += ":" + keySuffix + mtimeKey += ":" + keySuffix + } + + if err := store.SetMetadata(ctx, hashKey, currentHash); err != nil { + log.log("Warning: failed to update %s: %v", hashKey, err) + } + + exportTime := time.Now().Format(time.RFC3339) + if err := store.SetMetadata(ctx, timeKey, exportTime); err != nil { + log.log("Warning: failed to update %s: %v", timeKey, err) + } + + // Store mtime for fast-path optimization + if jsonlInfo, statErr := os.Stat(jsonlPath); statErr == nil { + mtimeStr := fmt.Sprintf("%d", jsonlInfo.ModTime().Unix()) + if err := store.SetMetadata(ctx, mtimeKey, mtimeStr); err != nil { + log.log("Warning: failed to update %s: %v", mtimeKey, err) + } + } +} + // validateDatabaseFingerprint checks that the database belongs to this repository func validateDatabaseFingerprint(ctx context.Context, store storage.Storage, log *daemonLogger) error { @@ -306,25 +383,17 @@ func createExportFunc(ctx context.Context, store storage.Storage, autoCommit, au } log.log("Exported to JSONL") - // Update last_import_hash metadata to prevent "content has changed" errors (bd-ymj fix) - // This keeps metadata in sync after export so next export doesn't fail - if currentHash, err := computeJSONLHash(jsonlPath); err == nil { - if err := store.SetMetadata(exportCtx, "last_import_hash", currentHash); err != nil { - log.log("Warning: failed to update last_import_hash: %v", err) - } - exportTime := time.Now().Format(time.RFC3339) - if err := store.SetMetadata(exportCtx, "last_import_time", exportTime); err != nil { - log.log("Warning: failed to update last_import_time: %v", err) - } - // Store mtime for fast-path optimization - if jsonlInfo, statErr := os.Stat(jsonlPath); statErr == nil { - mtimeStr := fmt.Sprintf("%d", jsonlInfo.ModTime().Unix()) - if err := store.SetMetadata(exportCtx, "last_import_mtime", mtimeStr); err != nil { - log.log("Warning: failed to update last_import_mtime: %v", err) - } + // Update export metadata (bd-ymj fix, bd-ar2.2 multi-repo support, bd-ar2.11 stable keys) + multiRepoPaths := getMultiRepoJSONLPaths() + if multiRepoPaths != nil { + // Multi-repo mode: update metadata for each JSONL with stable repo key + for _, path := range multiRepoPaths { + repoKey := getRepoKeyForPath(path) + updateExportMetadata(exportCtx, store, path, log, repoKey) } } else { - log.log("Warning: failed to compute JSONL hash for metadata update: %v", err) + // Single-repo mode: update metadata for main JSONL + updateExportMetadata(exportCtx, store, jsonlPath, log, "") } // Update database mtime to be >= JSONL mtime (fixes #278, #301, #321) @@ -408,7 +477,9 @@ func createAutoImportFunc(ctx context.Context, store storage.Storage, log daemon // Check JSONL content hash to avoid redundant imports // Use content-based check (not mtime) to avoid git resurrection bug (bd-khnb) - if !hasJSONLChanged(importCtx, store, jsonlPath) { + // Use getRepoKeyForPath for multi-repo support (bd-ar2.10, bd-ar2.11) + repoKey := getRepoKeyForPath(jsonlPath) + if !hasJSONLChanged(importCtx, store, jsonlPath, repoKey) { log.log("Skipping import: JSONL content unchanged") return } @@ -517,25 +588,16 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto } log.log("Exported to JSONL") - // Update last_import_hash metadata to prevent "content has changed" errors (bd-ymj fix) - // This keeps metadata in sync after export so next export doesn't fail - if currentHash, err := computeJSONLHash(jsonlPath); err == nil { - if err := store.SetMetadata(syncCtx, "last_import_hash", currentHash); err != nil { - log.log("Warning: failed to update last_import_hash: %v", err) - } - exportTime := time.Now().Format(time.RFC3339) - if err := store.SetMetadata(syncCtx, "last_import_time", exportTime); err != nil { - log.log("Warning: failed to update last_import_time: %v", err) - } - // Store mtime for fast-path optimization - if jsonlInfo, statErr := os.Stat(jsonlPath); statErr == nil { - mtimeStr := fmt.Sprintf("%d", jsonlInfo.ModTime().Unix()) - if err := store.SetMetadata(syncCtx, "last_import_mtime", mtimeStr); err != nil { - log.log("Warning: failed to update last_import_mtime: %v", err) - } + // Update export metadata (bd-ymj fix, bd-ar2.2 multi-repo support, bd-ar2.11 stable keys) + if multiRepoPaths != nil { + // Multi-repo mode: update metadata for each JSONL with stable repo key + for _, path := range multiRepoPaths { + repoKey := getRepoKeyForPath(path) + updateExportMetadata(syncCtx, store, path, log, repoKey) } } else { - log.log("Warning: failed to compute JSONL hash for metadata update: %v", err) + // Single-repo mode: update metadata for main JSONL + updateExportMetadata(syncCtx, store, jsonlPath, log, "") } // Update database mtime to be >= JSONL mtime (fixes #278, #301, #321) diff --git a/cmd/bd/daemon_sync_test.go b/cmd/bd/daemon_sync_test.go index c1be77b2..aab0c849 100644 --- a/cmd/bd/daemon_sync_test.go +++ b/cmd/bd/daemon_sync_test.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "fmt" "os" "path/filepath" "testing" @@ -334,25 +333,14 @@ func TestExportUpdatesMetadata(t *testing.T) { t.Fatalf("first export failed: %v", err) } - // Manually update metadata as daemon would (this is what we're testing) - // Note: In production, createExportFunc and createSyncFunc do this - currentHash, err := computeJSONLHash(jsonlPath) - if err != nil { - t.Fatalf("failed to compute JSONL hash: %v", err) - } - if err := store.SetMetadata(ctx, "last_import_hash", currentHash); err != nil { - t.Fatalf("failed to set last_import_hash: %v", err) - } - exportTime := time.Now().Format(time.RFC3339) - if err := store.SetMetadata(ctx, "last_import_time", exportTime); err != nil { - t.Fatalf("failed to set last_import_time: %v", err) - } - if jsonlInfo, statErr := os.Stat(jsonlPath); statErr == nil { - mtimeStr := jsonlInfo.ModTime().Unix() - if err := store.SetMetadata(ctx, "last_import_mtime", fmt.Sprintf("%d", mtimeStr)); err != nil { - t.Fatalf("failed to set last_import_mtime: %v", err) - } + // Update metadata using the actual daemon helper function (bd-ar2.3 fix) + // This verifies that updateExportMetadata (used by createExportFunc and createSyncFunc) works correctly + mockLogger := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, } + updateExportMetadata(ctx, store, jsonlPath, mockLogger, "") // Verify metadata was set lastHash, err := store.GetMetadata(ctx, "last_import_hash") @@ -381,3 +369,129 @@ func TestExportUpdatesMetadata(t *testing.T) { t.Fatalf("validatePreExport failed after metadata update: %v", err) } } + +func TestUpdateExportMetadataMultiRepo(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + jsonlPath1 := filepath.Join(tmpDir, "repo1", ".beads", "issues.jsonl") + jsonlPath2 := filepath.Join(tmpDir, "repo2", ".beads", "issues.jsonl") + + // Create storage + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Set issue_prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("failed to set issue_prefix: %v", err) + } + + // Create test issues for each repo + issue1 := &types.Issue{ + ID: "test-1", + Title: "Test Issue 1", + Description: "Repo 1 issue", + IssueType: types.TypeBug, + Priority: 1, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SourceRepo: "repo1", + } + issue2 := &types.Issue{ + ID: "test-2", + Title: "Test Issue 2", + Description: "Repo 2 issue", + IssueType: types.TypeBug, + Priority: 1, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SourceRepo: "repo2", + } + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("failed to create issue2: %v", err) + } + + // Create directories for JSONL files + if err := os.MkdirAll(filepath.Dir(jsonlPath1), 0755); err != nil { + t.Fatalf("failed to create dir for jsonlPath1: %v", err) + } + if err := os.MkdirAll(filepath.Dir(jsonlPath2), 0755); err != nil { + t.Fatalf("failed to create dir for jsonlPath2: %v", err) + } + + // Export issues to JSONL files + if err := exportToJSONLWithStore(ctx, store, jsonlPath1); err != nil { + t.Fatalf("failed to export to jsonlPath1: %v", err) + } + if err := exportToJSONLWithStore(ctx, store, jsonlPath2); err != nil { + t.Fatalf("failed to export to jsonlPath2: %v", err) + } + + // Create mock logger + mockLogger := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + + // Update metadata for each repo with different keys (bd-ar2.2 multi-repo support) + updateExportMetadata(ctx, store, jsonlPath1, mockLogger, jsonlPath1) + updateExportMetadata(ctx, store, jsonlPath2, mockLogger, jsonlPath2) + + // Verify per-repo metadata was set with correct keys + hash1Key := "last_import_hash:" + jsonlPath1 + hash1, err := store.GetMetadata(ctx, hash1Key) + if err != nil { + t.Fatalf("failed to get %s: %v", hash1Key, err) + } + if hash1 == "" { + t.Errorf("expected %s to be set", hash1Key) + } + + hash2Key := "last_import_hash:" + jsonlPath2 + hash2, err := store.GetMetadata(ctx, hash2Key) + if err != nil { + t.Fatalf("failed to get %s: %v", hash2Key, err) + } + if hash2 == "" { + t.Errorf("expected %s to be set", hash2Key) + } + + // Verify that single-repo metadata key is NOT set (we're using per-repo keys) + globalHash, err := store.GetMetadata(ctx, "last_import_hash") + if err != nil { + t.Fatalf("failed to get last_import_hash: %v", err) + } + if globalHash != "" { + t.Error("expected global last_import_hash to not be set when using per-repo keys") + } + + // Verify mtime metadata was also set per-repo + mtime1Key := "last_import_mtime:" + jsonlPath1 + mtime1, err := store.GetMetadata(ctx, mtime1Key) + if err != nil { + t.Fatalf("failed to get %s: %v", mtime1Key, err) + } + if mtime1 == "" { + t.Errorf("expected %s to be set", mtime1Key) + } + + mtime2Key := "last_import_mtime:" + jsonlPath2 + mtime2, err := store.GetMetadata(ctx, mtime2Key) + if err != nil { + t.Fatalf("failed to get %s: %v", mtime2Key, err) + } + if mtime2 == "" { + t.Errorf("expected %s to be set", mtime2Key) + } +} diff --git a/cmd/bd/export_mtime_test.go b/cmd/bd/export_mtime_test.go index 75390e92..c691c01f 100644 --- a/cmd/bd/export_mtime_test.go +++ b/cmd/bd/export_mtime_test.go @@ -61,6 +61,14 @@ func TestExportUpdatesDatabaseMtime(t *testing.T) { t.Fatalf("Export failed: %v", err) } + // Update metadata after export (bd-ymj fix) + mockLogger := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + updateExportMetadata(ctx, store, jsonlPath, mockLogger, "") + // Get JSONL mtime jsonlInfo, err := os.Stat(jsonlPath) if err != nil { @@ -158,6 +166,14 @@ func TestDaemonExportScenario(t *testing.T) { t.Fatalf("Daemon export failed: %v", err) } + // Daemon updates metadata after export (bd-ymj fix) + mockLogger := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + updateExportMetadata(ctx, store, jsonlPath, mockLogger, "") + // THIS IS THE FIX: daemon now calls TouchDatabaseFile after export if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil { t.Fatalf("TouchDatabaseFile failed: %v", err) @@ -229,6 +245,14 @@ func TestMultipleExportCycles(t *testing.T) { t.Fatalf("Cycle %d: Export failed: %v", i, err) } + // Update metadata after export (bd-ymj fix) + mockLogger := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + updateExportMetadata(ctx, store, jsonlPath, mockLogger, "") + // Apply fix if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil { t.Fatalf("Cycle %d: TouchDatabaseFile failed: %v", i, err) diff --git a/cmd/bd/integrity.go b/cmd/bd/integrity.go index b5219974..1d80b0cb 100644 --- a/cmd/bd/integrity.go +++ b/cmd/bd/integrity.go @@ -94,10 +94,20 @@ func computeJSONLHash(jsonlPath string) (string, error) { // Performance optimization: Checks mtime first as a fast-path. Only computes expensive // SHA256 hash if mtime changed. This makes 99% of checks instant (mtime unchanged = content // unchanged) while still catching git operations that restore old content with new mtimes. -func hasJSONLChanged(ctx context.Context, store storage.Storage, jsonlPath string) bool { +// +// In multi-repo mode, keySuffix should be the stable repo identifier (e.g., ".", "../frontend"). +func hasJSONLChanged(ctx context.Context, store storage.Storage, jsonlPath string, keySuffix string) bool { + // Build metadata keys with optional suffix for per-repo tracking (bd-ar2.10, bd-ar2.11) + hashKey := "last_import_hash" + mtimeKey := "last_import_mtime" + if keySuffix != "" { + hashKey += ":" + keySuffix + mtimeKey += ":" + keySuffix + } + // Fast-path: Check mtime first to avoid expensive hash computation // Get last known mtime from metadata - lastMtimeStr, err := store.GetMetadata(ctx, "last_import_mtime") + lastMtimeStr, err := store.GetMetadata(ctx, mtimeKey) if err == nil && lastMtimeStr != "" { // We have a previous mtime - check if file mtime changed jsonlInfo, statErr := os.Stat(jsonlPath) @@ -122,7 +132,7 @@ func hasJSONLChanged(ctx context.Context, store storage.Storage, jsonlPath strin } // Get last import hash from metadata - lastHash, err := store.GetMetadata(ctx, "last_import_hash") + lastHash, err := store.GetMetadata(ctx, hashKey) if err != nil { // No previous import hash - this is the first run or metadata is missing // Assume changed to trigger import @@ -138,7 +148,9 @@ func hasJSONLChanged(ctx context.Context, store storage.Storage, jsonlPath strin func validatePreExport(ctx context.Context, store storage.Storage, jsonlPath string) error { // Check if JSONL content has changed since last import - if so, must import first // Uses content-based detection (bd-xwo fix) instead of mtime-based to avoid false positives from git operations - if hasJSONLChanged(ctx, store, jsonlPath) { + // Use getRepoKeyForPath to get stable repo identifier for multi-repo support (bd-ar2.10, bd-ar2.11) + repoKey := getRepoKeyForPath(jsonlPath) + if hasJSONLChanged(ctx, store, jsonlPath, repoKey) { return fmt.Errorf("refusing to export: JSONL content has changed since last import (import first to avoid data loss)") } diff --git a/cmd/bd/integrity_test.go b/cmd/bd/integrity_test.go index cbc3b73f..2fa46c15 100644 --- a/cmd/bd/integrity_test.go +++ b/cmd/bd/integrity_test.go @@ -287,7 +287,7 @@ func TestHasJSONLChanged(t *testing.T) { } // Should return false (no change) - if hasJSONLChanged(ctx, store, jsonlPath) { + if hasJSONLChanged(ctx, store, jsonlPath, "") { t.Error("Expected hasJSONLChanged to return false for matching hash") } }) @@ -324,7 +324,7 @@ func TestHasJSONLChanged(t *testing.T) { } // Should return true (content changed) - if !hasJSONLChanged(ctx, store, jsonlPath) { + if !hasJSONLChanged(ctx, store, jsonlPath, "") { t.Error("Expected hasJSONLChanged to return true for different hash") } }) @@ -343,7 +343,7 @@ func TestHasJSONLChanged(t *testing.T) { } // Should return true (no previous hash, first run) - if !hasJSONLChanged(ctx, store, jsonlPath) { + if !hasJSONLChanged(ctx, store, jsonlPath, "") { t.Error("Expected hasJSONLChanged to return true for empty file with no metadata") } }) @@ -364,7 +364,7 @@ func TestHasJSONLChanged(t *testing.T) { } // No metadata stored - should return true (assume changed) - if !hasJSONLChanged(ctx, store, jsonlPath) { + if !hasJSONLChanged(ctx, store, jsonlPath, "") { t.Error("Expected hasJSONLChanged to return true when no metadata exists") } }) @@ -378,7 +378,7 @@ func TestHasJSONLChanged(t *testing.T) { store := newTestStore(t, dbPath) // File doesn't exist - should return false (don't auto-import broken files) - if hasJSONLChanged(ctx, store, jsonlPath) { + if hasJSONLChanged(ctx, store, jsonlPath, "") { t.Error("Expected hasJSONLChanged to return false for nonexistent file") } }) @@ -419,7 +419,7 @@ func TestHasJSONLChanged(t *testing.T) { } // Should return false using fast-path (mtime unchanged) - if hasJSONLChanged(ctx, store, jsonlPath) { + if hasJSONLChanged(ctx, store, jsonlPath, "") { t.Error("Expected hasJSONLChanged to return false using mtime fast-path") } }) @@ -467,7 +467,7 @@ func TestHasJSONLChanged(t *testing.T) { } // Should return false (content hasn't changed despite new mtime) - if hasJSONLChanged(ctx, store, jsonlPath) { + if hasJSONLChanged(ctx, store, jsonlPath, "") { t.Error("Expected hasJSONLChanged to return false for git operation with same content") } }) diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 20807ec7..3f2e670d 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -129,7 +129,9 @@ Use --merge to merge the sync branch back to main branch.`, // Smart conflict resolution: if JSONL content changed, auto-import first // Use content-based check (not mtime) to avoid git resurrection bug (bd-khnb) if err := ensureStoreActive(); err == nil && store != nil { - if hasJSONLChanged(ctx, store, jsonlPath) { + // Use getRepoKeyForPath for multi-repo support (bd-ar2.10, bd-ar2.11) + repoKey := getRepoKeyForPath(jsonlPath) + if hasJSONLChanged(ctx, store, jsonlPath, repoKey) { fmt.Println("→ JSONL content changed, importing first...") if err := importFromJSONL(ctx, jsonlPath, renameOnImport); err != nil { fmt.Fprintf(os.Stderr, "Error auto-importing: %v\n", err) diff --git a/internal/storage/sqlite/hash_ids.go b/internal/storage/sqlite/hash_ids.go index ef3e7405..3614390c 100644 --- a/internal/storage/sqlite/hash_ids.go +++ b/internal/storage/sqlite/hash_ids.go @@ -35,9 +35,9 @@ func (s *SQLiteStorage) GetNextChildID(ctx context.Context, parentID string) (st } if count == 0 { // Try to resurrect parent from JSONL history before failing (bd-dvd fix) - resurrected, err := s.TryResurrectParent(ctx, parentID) - if err != nil { - return "", fmt.Errorf("failed to resurrect parent %s: %w", parentID, err) + resurrected, resurrectErr := s.TryResurrectParent(ctx, parentID) + if resurrectErr != nil { + return "", fmt.Errorf("failed to resurrect parent %s: %w", parentID, resurrectErr) } if !resurrected { return "", fmt.Errorf("parent issue %s does not exist and could not be resurrected from JSONL history", parentID)