From 06c88558738faf8ddb9c327f2ea331eb5b95cd7b Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 06:59:51 -0800 Subject: [PATCH] feat: add daemon RPC endpoints for config and mol stale (bd-ag35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new RPC endpoints to allow CLI commands to work in daemon mode: 1. GetConfig (OpGetConfig) - Retrieves config values from the daemon database. Used by bd create to validate issue prefix in daemon mode. 2. MolStale (OpMolStale) - Finds stale molecules (complete-but-unclosed epics). Used by bd mol stale command in daemon mode. Changes: - internal/rpc/protocol.go: Add operation constants and request/response types - internal/rpc/client.go: Add client methods GetConfig() and MolStale() - internal/rpc/server_issues_epics.go: Add handler implementations - internal/rpc/server_routing_validation_diagnostics.go: Register handlers - cmd/bd/create.go: Use GetConfig RPC instead of skipping validation - cmd/bd/mol_stale.go: Use MolStale RPC instead of requiring --no-daemon - internal/rpc/coverage_test.go: Add tests for new endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/create.go | 8 +- cmd/bd/mol_stale.go | 44 +++-- internal/rpc/client.go | 30 ++++ internal/rpc/coverage_test.go | 167 ++++++++++++++++++ internal/rpc/protocol.go | 38 ++++ internal/rpc/server_issues_epics.go | 145 +++++++++++++++ .../server_routing_validation_diagnostics.go | 4 + 7 files changed, 423 insertions(+), 13 deletions(-) diff --git a/cmd/bd/create.go b/cmd/bd/create.go index bf6114f7..1031f322 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -222,8 +222,12 @@ var createCmd = &cobra.Command{ // Get database prefix from config var dbPrefix string if daemonClient != nil { - // TODO(bd-ag35): Add RPC method to get config in daemon mode - // For now, skip validation in daemon mode (needs RPC enhancement) + // Daemon mode - use RPC to get config + configResp, err := daemonClient.GetConfig(&rpc.GetConfigArgs{Key: "issue_prefix"}) + if err == nil { + dbPrefix = configResp.Value + } + // If error, continue without validation (non-fatal) } else { // Direct mode - check config dbPrefix, _ = store.GetConfig(ctx, "issue_prefix") diff --git a/cmd/bd/mol_stale.go b/cmd/bd/mol_stale.go index d510a6fc..dd46a735 100644 --- a/cmd/bd/mol_stale.go +++ b/cmd/bd/mol_stale.go @@ -6,6 +6,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" @@ -63,19 +64,40 @@ func runMolStale(cmd *cobra.Command, args []string) { var err error if daemonClient != nil { - // For now, stale check requires direct store access - // TODO(bd-ag35): Add RPC endpoint for stale check - fmt.Fprintf(os.Stderr, "Error: mol stale requires direct database access\n") - fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol stale\n") - os.Exit(1) - } + // Daemon mode - use RPC to get stale molecules + rpcResp, rpcErr := daemonClient.MolStale(&rpc.MolStaleArgs{ + BlockingOnly: blockingOnly, + UnassignedOnly: unassignedOnly, + ShowAll: showAll, + }) + if rpcErr != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", rpcErr) + os.Exit(1) + } + // Convert RPC response to local StaleResult + result = &StaleResult{ + TotalCount: rpcResp.TotalCount, + BlockingCount: rpcResp.BlockingCount, + } + for _, mol := range rpcResp.StaleMolecules { + result.StaleMolecules = append(result.StaleMolecules, &StaleMolecule{ + ID: mol.ID, + Title: mol.Title, + TotalChildren: mol.TotalChildren, + ClosedChildren: mol.ClosedChildren, + Assignee: mol.Assignee, + BlockingIssues: mol.BlockingIssues, + BlockingCount: mol.BlockingCount, + }) + } + } else { + if store == nil { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + os.Exit(1) + } - if store == nil { - fmt.Fprintf(os.Stderr, "Error: no database connection\n") - os.Exit(1) + result, err = findStaleMolecules(ctx, store, blockingOnly, unassignedOnly, showAll) } - - result, err = findStaleMolecules(ctx, store, blockingOnly, unassignedOnly, showAll) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/internal/rpc/client.go b/internal/rpc/client.go index 3bc330ac..8ec12150 100644 --- a/internal/rpc/client.go +++ b/internal/rpc/client.go @@ -442,6 +442,36 @@ func (c *Client) GetWorkerStatus(args *GetWorkerStatusArgs) (*GetWorkerStatusRes return &result, nil } +// GetConfig retrieves a config value from the daemon's database +func (c *Client) GetConfig(args *GetConfigArgs) (*GetConfigResponse, error) { + resp, err := c.Execute(OpGetConfig, args) + if err != nil { + return nil, err + } + + var result GetConfigResponse + if err := json.Unmarshal(resp.Data, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal config response: %w", err) + } + + return &result, nil +} + +// MolStale retrieves stale molecules (complete-but-unclosed) via the daemon +func (c *Client) MolStale(args *MolStaleArgs) (*MolStaleResponse, error) { + resp, err := c.Execute(OpMolStale, args) + if err != nil { + return nil, err + } + + var result MolStaleResponse + if err := json.Unmarshal(resp.Data, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal mol stale response: %w", err) + } + + return &result, nil +} + // cleanupStaleDaemonArtifacts removes stale daemon.pid file when socket is missing and lock is free. // This prevents stale artifacts from accumulating after daemon crashes. // Only removes pid file - lock file is managed by OS (released on process exit). diff --git a/internal/rpc/coverage_test.go b/internal/rpc/coverage_test.go index 1c428e92..85e06d32 100644 --- a/internal/rpc/coverage_test.go +++ b/internal/rpc/coverage_test.go @@ -296,3 +296,170 @@ func TestEpicStatus(t *testing.T) { t.Errorf("EpicStatus (eligible only) failed: %s", resp2.Error) } } + +func TestGetConfig(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + defer client.Close() + + // Test getting the issue_prefix config + args := &GetConfigArgs{Key: "issue_prefix"} + resp, err := client.GetConfig(args) + if err != nil { + t.Fatalf("GetConfig failed: %v", err) + } + + // Note: The test database may or may not have this config key set + // Success is indicated by the RPC returning without error + if resp.Key != "issue_prefix" { + t.Errorf("GetConfig returned wrong key: got %q, want %q", resp.Key, "issue_prefix") + } +} + +func TestGetConfig_UnknownKey(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + defer client.Close() + + // Test getting a non-existent config key - should return empty value + args := &GetConfigArgs{Key: "nonexistent_key"} + resp, err := client.GetConfig(args) + if err != nil { + t.Fatalf("GetConfig failed: %v", err) + } + + // Unknown keys return empty string (not an error) + if resp.Value != "" { + t.Errorf("GetConfig for unknown key returned non-empty value: %q", resp.Value) + } +} + +func TestMolStale(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + defer client.Close() + + // Test basic mol stale - should work even with no stale molecules + args := &MolStaleArgs{ + BlockingOnly: false, + UnassignedOnly: false, + ShowAll: false, + } + resp, err := client.MolStale(args) + if err != nil { + t.Fatalf("MolStale failed: %v", err) + } + + // TotalCount should be >= 0 + if resp.TotalCount < 0 { + t.Errorf("MolStale returned invalid TotalCount: %d", resp.TotalCount) + } +} + +func TestMolStale_WithStaleMolecule(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + defer client.Close() + + // Create an epic that will become stale (all children closed) + epicArgs := &CreateArgs{ + Title: "Test Stale Epic", + Description: "Epic that will become stale", + IssueType: "epic", + Priority: 2, + } + epicResp, err := client.Create(epicArgs) + if err != nil { + t.Fatalf("Create epic failed: %v", err) + } + + var epic types.Issue + json.Unmarshal(epicResp.Data, &epic) + + // Create and link a subtask + taskArgs := &CreateArgs{ + Title: "Subtask for stale test", + Description: "Will be closed", + IssueType: "task", + Priority: 2, + } + taskResp, err := client.Create(taskArgs) + if err != nil { + t.Fatalf("Create task failed: %v", err) + } + + var task types.Issue + json.Unmarshal(taskResp.Data, &task) + + // Link task to epic + depArgs := &DepAddArgs{ + FromID: task.ID, + ToID: epic.ID, + DepType: "parent-child", + } + _, err = client.AddDependency(depArgs) + if err != nil { + t.Fatalf("AddDependency failed: %v", err) + } + + // Close the subtask - epic should become stale + closeArgs := &CloseArgs{ID: task.ID, Reason: "Test complete"} + _, err = client.CloseIssue(closeArgs) + if err != nil { + t.Fatalf("CloseIssue failed: %v", err) + } + + // Now check for stale molecules + args := &MolStaleArgs{ + BlockingOnly: false, + UnassignedOnly: false, + ShowAll: false, + } + resp, err := client.MolStale(args) + if err != nil { + t.Fatalf("MolStale failed: %v", err) + } + + // Should find the stale epic + found := false + for _, mol := range resp.StaleMolecules { + if mol.ID == epic.ID { + found = true + if mol.TotalChildren != 1 { + t.Errorf("Expected 1 total child, got %d", mol.TotalChildren) + } + if mol.ClosedChildren != 1 { + t.Errorf("Expected 1 closed child, got %d", mol.ClosedChildren) + } + break + } + } + + if !found { + t.Errorf("Expected to find stale epic %s in results", epic.ID) + } +} + +func TestMolStale_BlockingOnly(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + defer client.Close() + + // Test with BlockingOnly filter + args := &MolStaleArgs{ + BlockingOnly: true, + UnassignedOnly: false, + ShowAll: false, + } + resp, err := client.MolStale(args) + if err != nil { + t.Fatalf("MolStale (blocking only) failed: %v", err) + } + + // All returned molecules should be blocking something + for _, mol := range resp.StaleMolecules { + if mol.BlockingCount == 0 { + t.Errorf("MolStale with BlockingOnly returned non-blocking molecule: %s", mol.ID) + } + } +} diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 490106db..a840bf07 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -43,6 +43,8 @@ const ( OpShutdown = "shutdown" OpDelete = "delete" OpGetWorkerStatus = "get_worker_status" + OpGetConfig = "get_config" + OpMolStale = "mol_stale" // Gate operations OpGateCreate = "gate_create" @@ -558,3 +560,39 @@ type MoleculeProgress struct { Assignee string `json:"assignee"` Steps []MoleculeStep `json:"steps"` } + +// GetConfigArgs represents arguments for getting daemon config +type GetConfigArgs struct { + Key string `json:"key"` // Config key to retrieve (e.g., "issue_prefix") +} + +// GetConfigResponse represents the response from get_config operation +type GetConfigResponse struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// MolStaleArgs represents arguments for the mol stale operation +type MolStaleArgs struct { + BlockingOnly bool `json:"blocking_only"` // Only show molecules blocking other work + UnassignedOnly bool `json:"unassigned_only"` // Only show unassigned molecules + ShowAll bool `json:"show_all"` // Include molecules with 0 children +} + +// StaleMolecule holds info about a stale molecule (for RPC response) +type StaleMolecule struct { + ID string `json:"id"` + Title string `json:"title"` + TotalChildren int `json:"total_children"` + ClosedChildren int `json:"closed_children"` + Assignee string `json:"assignee,omitempty"` + BlockingIssues []string `json:"blocking_issues,omitempty"` + BlockingCount int `json:"blocking_count"` +} + +// MolStaleResponse holds the result of the mol stale operation +type MolStaleResponse struct { + StaleMolecules []*StaleMolecule `json:"stale_molecules"` + TotalCount int `json:"total_count"` + BlockingCount int `json:"blocking_count"` +} diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index dc4de216..5d0ac7f4 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -1672,6 +1672,151 @@ func (s *Server) handleEpicStatus(req *Request) Response { } } +// handleGetConfig retrieves a config value from the database +func (s *Server) handleGetConfig(req *Request) Response { + var args GetConfigArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid get_config args: %v", err), + } + } + + store := s.storage + if store == nil { + return Response{ + Success: false, + Error: "storage not available", + } + } + + ctx := s.reqCtx(req) + + // Get config value from database + value, err := store.GetConfig(ctx, args.Key) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to get config %q: %v", args.Key, err), + } + } + + result := GetConfigResponse{ + Key: args.Key, + Value: value, + } + + data, _ := json.Marshal(result) + return Response{ + Success: true, + Data: data, + } +} + +// handleMolStale finds stale molecules (complete-but-unclosed) +func (s *Server) handleMolStale(req *Request) Response { + var args MolStaleArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid mol_stale args: %v", err), + } + } + + store := s.storage + if store == nil { + return Response{ + Success: false, + Error: "storage not available", + } + } + + ctx := s.reqCtx(req) + + // Get all epics eligible for closure (complete but unclosed) + epicStatuses, err := store.GetEpicsEligibleForClosure(ctx) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to query epics: %v", err), + } + } + + // Get blocked issues to find what each stale molecule is blocking + blockedIssues, err := store.GetBlockedIssues(ctx, types.WorkFilter{}) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to query blocked issues: %v", err), + } + } + + // Build map of issue ID -> what issues it's blocking + blockingMap := make(map[string][]string) + for _, blocked := range blockedIssues { + for _, blockerID := range blocked.BlockedBy { + blockingMap[blockerID] = append(blockingMap[blockerID], blocked.ID) + } + } + + var staleMolecules []*StaleMolecule + blockingCount := 0 + + for _, es := range epicStatuses { + // Skip if not eligible for close (not all children closed) + if !es.EligibleForClose { + continue + } + + // Skip if no children and not showing all + if es.TotalChildren == 0 && !args.ShowAll { + continue + } + + // Filter by unassigned if requested + if args.UnassignedOnly && es.Epic.Assignee != "" { + continue + } + + // Find what this molecule is blocking + blocking := blockingMap[es.Epic.ID] + blockingIssueCount := len(blocking) + + // Filter by blocking if requested + if args.BlockingOnly && blockingIssueCount == 0 { + continue + } + + mol := &StaleMolecule{ + ID: es.Epic.ID, + Title: es.Epic.Title, + TotalChildren: es.TotalChildren, + ClosedChildren: es.ClosedChildren, + Assignee: es.Epic.Assignee, + BlockingIssues: blocking, + BlockingCount: blockingIssueCount, + } + + staleMolecules = append(staleMolecules, mol) + + if blockingIssueCount > 0 { + blockingCount++ + } + } + + result := &MolStaleResponse{ + StaleMolecules: staleMolecules, + TotalCount: len(staleMolecules), + BlockingCount: blockingCount, + } + + data, _ := json.Marshal(result) + return Response{ + Success: true, + Data: data, + } +} + // Gate handlers func (s *Server) handleGateCreate(req *Request) Response { diff --git a/internal/rpc/server_routing_validation_diagnostics.go b/internal/rpc/server_routing_validation_diagnostics.go index b1b87161..3e51657f 100644 --- a/internal/rpc/server_routing_validation_diagnostics.go +++ b/internal/rpc/server_routing_validation_diagnostics.go @@ -225,6 +225,10 @@ func (s *Server) handleRequest(req *Request) Response { resp = s.handleGetMoleculeProgress(req) case OpGetWorkerStatus: resp = s.handleGetWorkerStatus(req) + case OpGetConfig: + resp = s.handleGetConfig(req) + case OpMolStale: + resp = s.handleMolStale(req) case OpShutdown: resp = s.handleShutdown(req) // Gate operations