Files
beads/internal/rpc/server_compact.go
vector-sigma 6408ef65f4 Fix #249: Add nil storage checks to prevent RPC daemon crashes (#250)
The daemon RPC server was crashing with a nil pointer dereference when the
global daemon received list, ready, stats, or other storage-dependent RPC
requests. The global daemon is created with nil storage, causing these
operations to panic when they attempted to access storage methods.

This fix adds defensive nil checks at the beginning of all RPC handlers
that require storage access. When storage is unavailable, they now return
a proper JSON error response instead of crashing the daemon.

The error message also informs users that the global daemon is deprecated
and they should use local daemons instead.

Handlers fixed:
- handleCreate, handleUpdate, handleClose
- handleList, handleShow, handleReady, handleStale
- handleResolveID, handleStats, handleEpicStatus
- handleCompact, handleCompactStats
- handleDepAdd (and via handleSimpleStoreOp for all label/dep/comment ops)

Co-authored-by: Test User <test@example.com>
2025-11-07 14:21:14 -08:00

288 lines
6.4 KiB
Go

package rpc
import (
"encoding/json"
"fmt"
"time"
"github.com/steveyegge/beads/internal/compact"
"github.com/steveyegge/beads/internal/storage/sqlite"
)
func (s *Server) handleCompact(req *Request) Response {
var args CompactArgs
if err := json.Unmarshal(req.Args, &args); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid compact args: %v", err),
}
}
store := s.storage
if store == nil {
return Response{
Success: false,
Error: "storage not available (global daemon deprecated - use local daemon instead with 'bd daemon' in your project)",
}
}
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if !ok {
return Response{
Success: false,
Error: "compact requires SQLite storage",
}
}
config := &compact.Config{
APIKey: args.APIKey,
Concurrency: args.Workers,
DryRun: args.DryRun,
}
if config.Concurrency <= 0 {
config.Concurrency = 5
}
compactor, err := compact.New(sqliteStore, args.APIKey, config)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to create compactor: %v", err),
}
}
ctx := s.reqCtx(req)
startTime := time.Now()
if args.IssueID != "" {
if !args.Force {
eligible, reason, err := sqliteStore.CheckEligibility(ctx, args.IssueID, args.Tier)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to check eligibility: %v", err),
}
}
if !eligible {
return Response{
Success: false,
Error: fmt.Sprintf("%s is not eligible for Tier %d compaction: %s", args.IssueID, args.Tier, reason),
}
}
}
issue, err := sqliteStore.GetIssue(ctx, args.IssueID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get issue: %v", err),
}
}
originalSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria)
if args.DryRun {
result := CompactResponse{
Success: true,
IssueID: args.IssueID,
OriginalSize: originalSize,
Reduction: "70-80%",
DryRun: true,
}
data, _ := json.Marshal(result)
return Response{
Success: true,
Data: data,
}
}
if args.Tier == 1 {
err = compactor.CompactTier1(ctx, args.IssueID)
} else {
return Response{
Success: false,
Error: "Tier 2 compaction not yet implemented",
}
}
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("compaction failed: %v", err),
}
}
issueAfter, _ := sqliteStore.GetIssue(ctx, args.IssueID)
compactedSize := 0
if issueAfter != nil {
compactedSize = len(issueAfter.Description)
}
duration := time.Since(startTime)
result := CompactResponse{
Success: true,
IssueID: args.IssueID,
OriginalSize: originalSize,
CompactedSize: compactedSize,
Reduction: fmt.Sprintf("%.1f%%", float64(originalSize-compactedSize)/float64(originalSize)*100),
Duration: duration.String(),
}
data, _ := json.Marshal(result)
return Response{
Success: true,
Data: data,
}
}
if args.All {
var candidates []*sqlite.CompactionCandidate
switch args.Tier {
case 1:
tier1, err := sqliteStore.GetTier1Candidates(ctx)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get Tier 1 candidates: %v", err),
}
}
candidates = tier1
case 2:
tier2, err := sqliteStore.GetTier2Candidates(ctx)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get Tier 2 candidates: %v", err),
}
}
candidates = tier2
default:
return Response{
Success: false,
Error: fmt.Sprintf("invalid tier: %d (must be 1 or 2)", args.Tier),
}
}
if len(candidates) == 0 {
result := CompactResponse{
Success: true,
Results: []CompactResult{},
}
data, _ := json.Marshal(result)
return Response{
Success: true,
Data: data,
}
}
issueIDs := make([]string, len(candidates))
for i, c := range candidates {
issueIDs[i] = c.IssueID
}
batchResults, err := compactor.CompactTier1Batch(ctx, issueIDs)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("batch compaction failed: %v", err),
}
}
results := make([]CompactResult, 0, len(batchResults))
for _, r := range batchResults {
result := CompactResult{
IssueID: r.IssueID,
Success: r.Err == nil,
OriginalSize: r.OriginalSize,
CompactedSize: r.CompactedSize,
}
if r.Err != nil {
result.Error = r.Err.Error()
} else if r.OriginalSize > 0 && r.CompactedSize > 0 {
result.Reduction = fmt.Sprintf("%.1f%%", float64(r.OriginalSize-r.CompactedSize)/float64(r.OriginalSize)*100)
}
results = append(results, result)
}
duration := time.Since(startTime)
response := CompactResponse{
Success: true,
Results: results,
Duration: duration.String(),
DryRun: args.DryRun,
}
data, _ := json.Marshal(response)
return Response{
Success: true,
Data: data,
}
}
return Response{
Success: false,
Error: "must specify --all or --id",
}
}
func (s *Server) handleCompactStats(req *Request) Response {
var args CompactStatsArgs
if err := json.Unmarshal(req.Args, &args); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid compact stats args: %v", err),
}
}
store := s.storage
if store == nil {
return Response{
Success: false,
Error: "storage not available (global daemon deprecated - use local daemon instead with 'bd daemon' in your project)",
}
}
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if !ok {
return Response{
Success: false,
Error: "compact stats requires SQLite storage",
}
}
ctx := s.reqCtx(req)
tier1, err := sqliteStore.GetTier1Candidates(ctx)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get Tier 1 candidates: %v", err),
}
}
tier2, err := sqliteStore.GetTier2Candidates(ctx)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get Tier 2 candidates: %v", err),
}
}
stats := CompactStatsData{
Tier1Candidates: len(tier1),
Tier2Candidates: len(tier2),
Tier1MinAge: "30 days",
Tier2MinAge: "90 days",
TotalClosed: 0, // Could query for this but not critical
}
result := CompactResponse{
Success: true,
Stats: &stats,
}
data, _ := json.Marshal(result)
return Response{
Success: true,
Data: data,
}
}