feat: add daemon RPC endpoints for config and mol stale (bd-ag35)

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-30 06:59:51 -08:00
parent 7f5378ba26
commit 06c8855873
7 changed files with 423 additions and 13 deletions

View File

@@ -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")

View File

@@ -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)

View File

@@ -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).

View File

@@ -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)
}
}
}

View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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