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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user