diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 49f9e5fd..8575fddf 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -35,10 +35,11 @@ const ( OpExport = "export" OpImport = "import" OpEpicStatus = "epic_status" - OpGetMutations = "get_mutations" - OpShutdown = "shutdown" - OpDelete = "delete" - OpGetWorkerStatus = "get_worker_status" + OpGetMutations = "get_mutations" + OpGetMoleculeProgress = "get_molecule_progress" + OpShutdown = "shutdown" + OpDelete = "delete" + OpGetWorkerStatus = "get_worker_status" // Gate operations (bd-likt) OpGateCreate = "gate_create" @@ -489,3 +490,25 @@ type WorkerStatus struct { type GetWorkerStatusResponse struct { Workers []WorkerStatus `json:"workers"` } + +// GetMoleculeProgressArgs represents arguments for the get_molecule_progress operation +type GetMoleculeProgressArgs struct { + MoleculeID string `json:"molecule_id"` // The ID of the molecule (parent issue) +} + +// MoleculeStep represents a single step within a molecule +type MoleculeStep struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` // "done", "current", "ready", "blocked" + StartTime *string `json:"start_time"` // ISO 8601 timestamp when step was created + CloseTime *string `json:"close_time"` // ISO 8601 timestamp when step was closed (if done) +} + +// MoleculeProgress represents the progress of a molecule (parent issue with steps) +type MoleculeProgress struct { + MoleculeID string `json:"molecule_id"` + Title string `json:"title"` + Assignee string `json:"assignee"` + Steps []MoleculeStep `json:"steps"` +} diff --git a/internal/rpc/server_core.go b/internal/rpc/server_core.go index f2adc64f..5fc6aee0 100644 --- a/internal/rpc/server_core.go +++ b/internal/rpc/server_core.go @@ -1,6 +1,7 @@ package rpc import ( + "context" "encoding/json" "fmt" "net" @@ -10,6 +11,7 @@ import ( "time" "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/types" ) // ServerVersion is the version of this RPC server @@ -232,3 +234,120 @@ func (s *Server) handleGetMutations(req *Request) Response { Data: data, } } + +// handleGetMoleculeProgress handles the get_molecule_progress RPC operation +// Returns detailed progress for a molecule (parent issue with child steps) +func (s *Server) handleGetMoleculeProgress(req *Request) Response { + var args GetMoleculeProgressArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid arguments: %v", err), + } + } + + store := s.storage + if store == nil { + return Response{ + Success: false, + Error: "storage not available", + } + } + + ctx := s.reqCtx(req) + + // Get the molecule (parent issue) + molecule, err := store.GetIssue(ctx, args.MoleculeID) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to get molecule: %v", err), + } + } + if molecule == nil { + return Response{ + Success: false, + Error: fmt.Sprintf("molecule not found: %s", args.MoleculeID), + } + } + + // Get children (issues that have parent-child dependency on this molecule) + var children []*types.IssueWithDependencyMetadata + if sqliteStore, ok := store.(interface { + GetDependentsWithMetadata(ctx context.Context, issueID string) ([]*types.IssueWithDependencyMetadata, error) + }); ok { + allDependents, err := sqliteStore.GetDependentsWithMetadata(ctx, args.MoleculeID) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to get molecule children: %v", err), + } + } + // Filter for parent-child relationships only + for _, dep := range allDependents { + if dep.DependencyType == types.DepParentChild { + children = append(children, dep) + } + } + } + + // Get blocked issue IDs for status computation + blockedIDs := make(map[string]bool) + if sqliteStore, ok := store.(interface { + GetBlockedIssueIDs(ctx context.Context) ([]string, error) + }); ok { + ids, err := sqliteStore.GetBlockedIssueIDs(ctx) + if err == nil { + for _, id := range ids { + blockedIDs[id] = true + } + } + } + + // Build steps from children + steps := make([]MoleculeStep, 0, len(children)) + for _, child := range children { + step := MoleculeStep{ + ID: child.ID, + Title: child.Title, + } + + // Compute step status + switch child.Status { + case types.StatusClosed: + step.Status = "done" + case types.StatusInProgress: + step.Status = "current" + default: // open, blocked, etc. + if blockedIDs[child.ID] { + step.Status = "blocked" + } else { + step.Status = "ready" + } + } + + // Set timestamps + startTime := child.CreatedAt.Format(time.RFC3339) + step.StartTime = &startTime + + if child.ClosedAt != nil { + closeTime := child.ClosedAt.Format(time.RFC3339) + step.CloseTime = &closeTime + } + + steps = append(steps, step) + } + + progress := MoleculeProgress{ + MoleculeID: molecule.ID, + Title: molecule.Title, + Assignee: molecule.Assignee, + Steps: steps, + } + + data, _ := json.Marshal(progress) + return Response{ + Success: true, + Data: data, + } +} diff --git a/internal/rpc/server_routing_validation_diagnostics.go b/internal/rpc/server_routing_validation_diagnostics.go index 5c258c49..fc99b0e4 100644 --- a/internal/rpc/server_routing_validation_diagnostics.go +++ b/internal/rpc/server_routing_validation_diagnostics.go @@ -219,6 +219,8 @@ func (s *Server) handleRequest(req *Request) Response { resp = s.handleEpicStatus(req) case OpGetMutations: resp = s.handleGetMutations(req) + case OpGetMoleculeProgress: + resp = s.handleGetMoleculeProgress(req) case OpGetWorkerStatus: resp = s.handleGetWorkerStatus(req) case OpShutdown: diff --git a/internal/storage/sqlite/blocked_cache.go b/internal/storage/sqlite/blocked_cache.go index e592d507..93d63f03 100644 --- a/internal/storage/sqlite/blocked_cache.go +++ b/internal/storage/sqlite/blocked_cache.go @@ -246,3 +246,22 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er func (s *SQLiteStorage) invalidateBlockedCache(ctx context.Context, exec execer) error { return s.rebuildBlockedCache(ctx, exec) } + +// GetBlockedIssueIDs returns all issue IDs currently in the blocked cache +func (s *SQLiteStorage) GetBlockedIssueIDs(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, "SELECT issue_id FROM blocked_issues_cache") + if err != nil { + return nil, fmt.Errorf("failed to query blocked_issues_cache: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("failed to scan blocked issue ID: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +}