package rpc import ( "context" "encoding/json" "fmt" "os" "path/filepath" "sort" "github.com/steveyegge/beads/internal/autoimport" "github.com/steveyegge/beads/internal/importer" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) // handleExport handles the export operation func (s *Server) handleExport(req *Request) Response { var exportArgs ExportArgs if err := json.Unmarshal(req.Args, &exportArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid export args: %v", err), } } store := s.storage ctx := s.reqCtx(req) // Get all issues issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get issues: %v", err), } } // Sort by ID for consistent output sort.Slice(issues, func(i, j int) bool { return issues[i].ID < issues[j].ID }) // Populate dependencies for all issues (avoid N+1) allDeps, err := store.GetAllDependencyRecords(ctx) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get dependencies: %v", err), } } for _, issue := range issues { issue.Dependencies = allDeps[issue.ID] } // Populate labels for all issues for _, issue := range issues { labels, err := store.GetLabels(ctx, issue.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get labels for %s: %v", issue.ID, err), } } issue.Labels = labels } // Populate comments for all issues for _, issue := range issues { comments, err := store.GetIssueComments(ctx, issue.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get comments for %s: %v", issue.ID, err), } } issue.Comments = comments } // Create temp file for atomic write dir := filepath.Dir(exportArgs.JSONLPath) base := filepath.Base(exportArgs.JSONLPath) tempFile, err := os.CreateTemp(dir, base+".tmp.*") if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to create temp file: %v", err), } } tempPath := tempFile.Name() defer func() { _ = tempFile.Close() _ = os.Remove(tempPath) }() // Write JSONL encoder := json.NewEncoder(tempFile) exportedIDs := make([]string, 0, len(issues)) for _, issue := range issues { if err := encoder.Encode(issue); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to encode issue %s: %v", issue.ID, err), } } exportedIDs = append(exportedIDs, issue.ID) } // Close temp file before rename _ = tempFile.Close() // Atomic replace if err := os.Rename(tempPath, exportArgs.JSONLPath); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to replace JSONL file: %v", err), } } // Set appropriate file permissions (0600: rw-------) if err := os.Chmod(exportArgs.JSONLPath, 0600); err != nil { // Non-fatal, just log fmt.Fprintf(os.Stderr, "Warning: failed to set file permissions: %v\n", err) } // Clear dirty flags for exported issues if err := store.ClearDirtyIssuesByID(ctx, exportedIDs); err != nil { // Non-fatal, just log fmt.Fprintf(os.Stderr, "Warning: failed to clear dirty flags: %v\n", err) } result := map[string]interface{}{ "exported_count": len(exportedIDs), "path": exportArgs.JSONLPath, } data, _ := json.Marshal(result) return Response{ Success: true, Data: data, } } // handleImport handles the import operation func (s *Server) handleImport(req *Request) Response { var importArgs ImportArgs if err := json.Unmarshal(req.Args, &importArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid import args: %v", err), } } // Note: The actual import logic is complex and lives in cmd/bd/import.go // For now, we'll return an error suggesting to use direct mode // In the future, we can refactor the import logic into a shared package return Response{ Success: false, Error: "import via daemon not yet implemented, use --no-daemon flag", } } // checkAndAutoImportIfStale checks if JSONL is newer than last import and triggers auto-import // This fixes bd-132: daemon shows stale data after git pull func (s *Server) checkAndAutoImportIfStale(req *Request) error { // Get storage for this request store := s.storage ctx := s.reqCtx(req) // Get database path from storage sqliteStore, ok := store.(*sqlite.SQLiteStorage) if !ok { return fmt.Errorf("storage is not SQLiteStorage") } dbPath := sqliteStore.Path() // Fast path: Check if JSONL is stale using cheap mtime check // This avoids reading/hashing JSONL on every request isStale, err := autoimport.CheckStaleness(ctx, store, dbPath) if err != nil || !isStale { return err } // Single-flight guard: Only allow one import at a time // If import is already running, skip and let the request proceed if !s.importInProgress.CompareAndSwap(false, true) { if os.Getenv("BD_DEBUG") != "" { fmt.Fprintf(os.Stderr, "Debug: auto-import already in progress, skipping\n") } return nil } defer s.importInProgress.Store(false) // Double-check staleness after acquiring lock (another goroutine may have imported) isStale, err = autoimport.CheckStaleness(ctx, store, dbPath) if err != nil || !isStale { return err } if os.Getenv("BD_DEBUG") != "" { fmt.Fprintf(os.Stderr, "Debug: daemon detected stale JSONL, auto-importing...\n") } // Perform actual import notify := autoimport.NewStderrNotifier(os.Getenv("BD_DEBUG") != "") importFunc := func(ctx context.Context, issues []*types.Issue) (created, updated int, idMapping map[string]string, err error) { // Use the importer package to perform the actual import result, err := importer.ImportIssues(ctx, dbPath, store, issues, importer.Options{ RenameOnImport: true, // Auto-rename prefix mismatches // Note: SkipPrefixValidation is false by default, so we validate and rename }) if err != nil { return 0, 0, nil, err } return result.Created, result.Updated, result.IDMapping, nil } onChanged := func(needsFullExport bool) { // When IDs are remapped, trigger export so JSONL reflects the new IDs if needsFullExport { // Use a goroutine to avoid blocking the import go func() { if err := s.triggerExport(ctx, store, dbPath); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to export after auto-import: %v\n", err) } }() } } return autoimport.AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, onChanged) } // triggerExport exports all issues to JSONL after auto-import remaps IDs func (s *Server) triggerExport(ctx context.Context, store storage.Storage, dbPath string) error { // Find JSONL path using database directory 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") } // Get all issues from storage sqliteStore, ok := store.(*sqlite.SQLiteStorage) if !ok { return fmt.Errorf("storage is not SQLiteStorage") } // Export to JSONL (this will update the file with remapped IDs) allIssues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { return fmt.Errorf("failed to fetch issues for export: %w", err) } // Write to JSONL file // Note: We reuse the export logic from the daemon's existing export mechanism // For now, this is a simple implementation - could be refactored to share with cmd/bd file, err := os.Create(jsonlPath) // #nosec G304 - controlled path from config if err != nil { return fmt.Errorf("failed to create JSONL file: %w", err) } defer file.Close() encoder := json.NewEncoder(file) for _, issue := range allIssues { if err := encoder.Encode(issue); err != nil { return fmt.Errorf("failed to encode issue %s: %w", issue.ID, err) } } return nil }