package rpc import ( "context" "encoding/json" "fmt" "os" "strings" "time" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/util" "github.com/steveyegge/beads/internal/utils" ) // parseTimeRPC parses time strings in multiple formats (RFC3339, YYYY-MM-DD, etc.) // Matches the parseTimeFlag behavior in cmd/bd/list.go for CLI parity func parseTimeRPC(s string) (time.Time, error) { // Try RFC3339 first (ISO 8601 with timezone) if t, err := time.Parse(time.RFC3339, s); err == nil { return t, nil } // Try YYYY-MM-DD format (common user input) if t, err := time.Parse("2006-01-02", s); err == nil { return t, nil } // Try YYYY-MM-DD HH:MM:SS format if t, err := time.Parse("2006-01-02 15:04:05", s); err == nil { return t, nil } return time.Time{}, fmt.Errorf("unsupported date format: %q (use YYYY-MM-DD or RFC3339)", s) } func strValue(p *string) string { if p == nil { return "" } return *p } func updatesFromArgs(a UpdateArgs) map[string]interface{} { u := map[string]interface{}{} if a.Title != nil { u["title"] = *a.Title } if a.Description != nil { u["description"] = *a.Description } if a.Status != nil { u["status"] = *a.Status } if a.Priority != nil { u["priority"] = *a.Priority } if a.Design != nil { u["design"] = *a.Design } if a.AcceptanceCriteria != nil { u["acceptance_criteria"] = *a.AcceptanceCriteria } if a.Notes != nil { u["notes"] = *a.Notes } if a.Assignee != nil { u["assignee"] = *a.Assignee } if a.ExternalRef != nil { u["external_ref"] = *a.ExternalRef } if a.EstimatedMinutes != nil { u["estimated_minutes"] = *a.EstimatedMinutes } if a.IssueType != nil { u["issue_type"] = *a.IssueType } // Messaging fields if a.Sender != nil { u["sender"] = *a.Sender } if a.Ephemeral != nil { u["ephemeral"] = *a.Ephemeral } if a.RepliesTo != nil { u["replies_to"] = *a.RepliesTo } // Graph link fields if a.RelatesTo != nil { u["relates_to"] = *a.RelatesTo } if a.DuplicateOf != nil { u["duplicate_of"] = *a.DuplicateOf } if a.SupersededBy != nil { u["superseded_by"] = *a.SupersededBy } // Pinned field if a.Pinned != nil { u["pinned"] = *a.Pinned } // Agent slot fields if a.HookBead != nil { u["hook_bead"] = *a.HookBead } if a.RoleBead != nil { u["role_bead"] = *a.RoleBead } // Agent state fields if a.AgentState != nil { u["agent_state"] = *a.AgentState } if a.LastActivity != nil && *a.LastActivity { u["last_activity"] = time.Now() } return u } func (s *Server) handleCreate(req *Request) Response { var createArgs CreateArgs if err := json.Unmarshal(req.Args, &createArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid create args: %v", err), } } // Check for conflicting flags if createArgs.ID != "" && createArgs.Parent != "" { return Response{ Success: false, Error: "cannot specify both ID and Parent", } } // Warn if creating an issue without a description (unless it's a test issue) if createArgs.Description == "" && !strings.Contains(strings.ToLower(createArgs.Title), "test") { // Log warning to daemon logs (stderr goes to daemon logs) fmt.Fprintf(os.Stderr, "[WARNING] Creating issue '%s' without description. Issues without descriptions lack context for future work.\n", createArgs.Title) } 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)", } } ctx := s.reqCtx(req) // If parent is specified, generate child ID issueID := createArgs.ID if createArgs.Parent != "" { childID, err := store.GetNextChildID(ctx, createArgs.Parent) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to generate child ID: %v", err), } } issueID = childID } var design, acceptance, assignee, externalRef *string if createArgs.Design != "" { design = &createArgs.Design } if createArgs.AcceptanceCriteria != "" { acceptance = &createArgs.AcceptanceCriteria } if createArgs.Assignee != "" { assignee = &createArgs.Assignee } if createArgs.ExternalRef != "" { externalRef = &createArgs.ExternalRef } issue := &types.Issue{ ID: issueID, Title: createArgs.Title, Description: createArgs.Description, IssueType: types.IssueType(createArgs.IssueType), Priority: createArgs.Priority, Design: strValue(design), AcceptanceCriteria: strValue(acceptance), Assignee: strValue(assignee), ExternalRef: externalRef, EstimatedMinutes: createArgs.EstimatedMinutes, Status: types.StatusOpen, // Messaging fields Sender: createArgs.Sender, Ephemeral: createArgs.Ephemeral, // NOTE: RepliesTo now handled via replies-to dependency (Decision 004) // ID generation IDPrefix: createArgs.IDPrefix, CreatedBy: createArgs.CreatedBy, // Molecule type MolType: types.MolType(createArgs.MolType), } // Check if any dependencies are discovered-from type // If so, inherit source_repo from the parent issue var discoveredFromParentID string for _, depSpec := range createArgs.Dependencies { depSpec = strings.TrimSpace(depSpec) if depSpec == "" { continue } var depType types.DependencyType var dependsOnID string if strings.Contains(depSpec, ":") { parts := strings.SplitN(depSpec, ":", 2) if len(parts) == 2 { depType = types.DependencyType(strings.TrimSpace(parts[0])) dependsOnID = strings.TrimSpace(parts[1]) if depType == types.DepDiscoveredFrom { discoveredFromParentID = dependsOnID break } } } } // If we found a discovered-from dependency, inherit source_repo from parent if discoveredFromParentID != "" { parentIssue, err := store.GetIssue(ctx, discoveredFromParentID) if err == nil && parentIssue.SourceRepo != "" { issue.SourceRepo = parentIssue.SourceRepo } // If error getting parent or parent has no source_repo, continue with default } if err := store.CreateIssue(ctx, issue, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to create issue: %v", err), } } // If parent was specified, add parent-child dependency if createArgs.Parent != "" { dep := &types.Dependency{ IssueID: issue.ID, DependsOnID: createArgs.Parent, Type: types.DepParentChild, } if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to add parent-child dependency %s -> %s: %v", issue.ID, createArgs.Parent, err), } } } // If RepliesTo was specified, add replies-to dependency (Decision 004) if createArgs.RepliesTo != "" { dep := &types.Dependency{ IssueID: issue.ID, DependsOnID: createArgs.RepliesTo, Type: types.DepRepliesTo, ThreadID: createArgs.RepliesTo, // Use parent ID as thread root } if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to add replies-to dependency %s -> %s: %v", issue.ID, createArgs.RepliesTo, err), } } } // Add labels if specified for _, label := range createArgs.Labels { if err := store.AddLabel(ctx, issue.ID, label, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to add label %s: %v", label, err), } } } // Add dependencies if specified for _, depSpec := range createArgs.Dependencies { depSpec = strings.TrimSpace(depSpec) if depSpec == "" { continue } var depType types.DependencyType var dependsOnID string if strings.Contains(depSpec, ":") { parts := strings.SplitN(depSpec, ":", 2) if len(parts) != 2 { return Response{ Success: false, Error: fmt.Sprintf("invalid dependency format '%s', expected 'type:id' or 'id'", depSpec), } } depType = types.DependencyType(strings.TrimSpace(parts[0])) dependsOnID = strings.TrimSpace(parts[1]) } else { depType = types.DepBlocks dependsOnID = depSpec } if !depType.IsValid() { return Response{ Success: false, Error: fmt.Sprintf("invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)", depType), } } dep := &types.Dependency{ IssueID: issue.ID, DependsOnID: dependsOnID, Type: depType, } if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to add dependency %s -> %s: %v", issue.ID, dependsOnID, err), } } } // Add waits-for dependency if specified if createArgs.WaitsFor != "" { // Validate gate type gate := createArgs.WaitsForGate if gate == "" { gate = types.WaitsForAllChildren } if gate != types.WaitsForAllChildren && gate != types.WaitsForAnyChildren { return Response{ Success: false, Error: fmt.Sprintf("invalid waits_for_gate value '%s' (valid: all-children, any-children)", gate), } } // Create metadata JSON meta := types.WaitsForMeta{ Gate: gate, } metaJSON, err := json.Marshal(meta) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to serialize waits-for metadata: %v", err), } } dep := &types.Dependency{ IssueID: issue.ID, DependsOnID: createArgs.WaitsFor, Type: types.DepWaitsFor, Metadata: string(metaJSON), } if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to add waits-for dependency %s -> %s: %v", issue.ID, createArgs.WaitsFor, err), } } } // Emit mutation event for event-driven daemon s.emitMutation(MutationCreate, issue.ID, issue.Title, issue.Assignee) data, _ := json.Marshal(issue) return Response{ Success: true, Data: data, } } func (s *Server) handleUpdate(req *Request) Response { var updateArgs UpdateArgs if err := json.Unmarshal(req.Args, &updateArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid update 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)", } } ctx := s.reqCtx(req) // Check if issue is a template (beads-1ra): templates are read-only issue, err := store.GetIssue(ctx, updateArgs.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get issue: %v", err), } } if issue == nil { return Response{ Success: false, Error: fmt.Sprintf("issue %s not found", updateArgs.ID), } } if issue.IsTemplate { return Response{ Success: false, Error: fmt.Sprintf("cannot update template %s: templates are read-only; use 'bd molecule instantiate' to create a work item", updateArgs.ID), } } updates := updatesFromArgs(updateArgs) actor := s.reqActor(req) // Apply regular field updates if any if len(updates) > 0 { if err := store.UpdateIssue(ctx, updateArgs.ID, updates, actor); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to update issue: %v", err), } } } // Handle label operations // Set labels (replaces all existing labels) if len(updateArgs.SetLabels) > 0 { // Get current labels currentLabels, err := store.GetLabels(ctx, updateArgs.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get current labels: %v", err), } } // Remove all current labels for _, label := range currentLabels { if err := store.RemoveLabel(ctx, updateArgs.ID, label, actor); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to remove label %s: %v", label, err), } } } // Add new labels for _, label := range updateArgs.SetLabels { if err := store.AddLabel(ctx, updateArgs.ID, label, actor); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to set label %s: %v", label, err), } } } } // Add labels for _, label := range updateArgs.AddLabels { if err := store.AddLabel(ctx, updateArgs.ID, label, actor); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to add label %s: %v", label, err), } } } // Remove labels for _, label := range updateArgs.RemoveLabels { if err := store.RemoveLabel(ctx, updateArgs.ID, label, actor); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to remove label %s: %v", label, err), } } } // Handle reparenting if updateArgs.Parent != nil { newParentID := *updateArgs.Parent // Validate new parent exists (unless empty string to remove parent) if newParentID != "" { newParent, err := store.GetIssue(ctx, newParentID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get new parent: %v", err), } } if newParent == nil { return Response{ Success: false, Error: fmt.Sprintf("parent issue %s not found", newParentID), } } } // Find and remove existing parent-child dependency deps, err := store.GetDependencyRecords(ctx, updateArgs.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get dependencies: %v", err), } } for _, dep := range deps { if dep.Type == types.DepParentChild { if err := store.RemoveDependency(ctx, updateArgs.ID, dep.DependsOnID, actor); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to remove old parent dependency: %v", err), } } break // Only one parent-child dependency expected } } // Add new parent-child dependency (if not removing parent) if newParentID != "" { newDep := &types.Dependency{ IssueID: updateArgs.ID, DependsOnID: newParentID, Type: types.DepParentChild, } if err := store.AddDependency(ctx, newDep, actor); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to add parent dependency: %v", err), } } } } // Emit mutation event for event-driven daemon (only if any updates or label/parent operations were performed) if len(updates) > 0 || len(updateArgs.SetLabels) > 0 || len(updateArgs.AddLabels) > 0 || len(updateArgs.RemoveLabels) > 0 || updateArgs.Parent != nil { // Check if this was a status change - emit rich MutationStatus event if updateArgs.Status != nil && *updateArgs.Status != string(issue.Status) { s.emitRichMutation(MutationEvent{ Type: MutationStatus, IssueID: updateArgs.ID, Title: issue.Title, Assignee: issue.Assignee, OldStatus: string(issue.Status), NewStatus: *updateArgs.Status, }) } else { s.emitMutation(MutationUpdate, updateArgs.ID, issue.Title, issue.Assignee) } } updatedIssue, getErr := store.GetIssue(ctx, updateArgs.ID) if getErr != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get updated issue: %v", getErr), } } data, _ := json.Marshal(updatedIssue) return Response{ Success: true, Data: data, } } func (s *Server) handleClose(req *Request) Response { var closeArgs CloseArgs if err := json.Unmarshal(req.Args, &closeArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid close 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)", } } ctx := s.reqCtx(req) // Check if issue is a template (beads-1ra): templates are read-only issue, err := store.GetIssue(ctx, closeArgs.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get issue: %v", err), } } if issue != nil && issue.IsTemplate { return Response{ Success: false, Error: fmt.Sprintf("cannot close template %s: templates are read-only", closeArgs.ID), } } // Capture old status for rich mutation event oldStatus := "" if issue != nil { oldStatus = string(issue.Status) } if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to close issue: %v", err), } } // Emit rich status change event for event-driven daemon s.emitRichMutation(MutationEvent{ Type: MutationStatus, IssueID: closeArgs.ID, Title: issue.Title, Assignee: issue.Assignee, OldStatus: oldStatus, NewStatus: "closed", }) closedIssue, _ := store.GetIssue(ctx, closeArgs.ID) // If SuggestNext is requested, find newly unblocked issues (GH#679) if closeArgs.SuggestNext { unblocked, err := store.GetNewlyUnblockedByClose(ctx, closeArgs.ID) if err != nil { // Non-fatal: still return the closed issue unblocked = nil } result := CloseResult{ Closed: closedIssue, Unblocked: unblocked, } data, _ := json.Marshal(result) return Response{ Success: true, Data: data, } } // Backward compatible: just return the closed issue data, _ := json.Marshal(closedIssue) return Response{ Success: true, Data: data, } } func (s *Server) handleDelete(req *Request) Response { var deleteArgs DeleteArgs if err := json.Unmarshal(req.Args, &deleteArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid delete 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)", } } // Validate that we have issue IDs to delete if len(deleteArgs.IDs) == 0 { return Response{ Success: false, Error: "no issue IDs provided for deletion", } } // DryRun mode: just return what would be deleted if deleteArgs.DryRun { data, _ := json.Marshal(map[string]interface{}{ "dry_run": true, "issue_count": len(deleteArgs.IDs), "issues": deleteArgs.IDs, }) return Response{ Success: true, Data: data, } } ctx := s.reqCtx(req) deletedCount := 0 errors := make([]string, 0) // Delete each issue for _, issueID := range deleteArgs.IDs { // Verify issue exists before deleting issue, err := store.GetIssue(ctx, issueID) if err != nil { errors = append(errors, fmt.Sprintf("%s: %v", issueID, err)) continue } if issue == nil { errors = append(errors, fmt.Sprintf("%s: not found", issueID)) continue } // Check if issue is a template (beads-1ra): templates are read-only if issue.IsTemplate { errors = append(errors, fmt.Sprintf("%s: cannot delete template (templates are read-only)", issueID)) continue } // Create tombstone instead of hard delete // This preserves deletion history and prevents resurrection during sync type tombstoner interface { CreateTombstone(ctx context.Context, id string, actor string, reason string) error } if t, ok := store.(tombstoner); ok { reason := deleteArgs.Reason if reason == "" { reason = "deleted via daemon" } if err := t.CreateTombstone(ctx, issueID, "daemon", reason); err != nil { errors = append(errors, fmt.Sprintf("%s: %v", issueID, err)) continue } } else { // Fallback to hard delete if CreateTombstone not available if err := store.DeleteIssue(ctx, issueID); err != nil { errors = append(errors, fmt.Sprintf("%s: %v", issueID, err)) continue } } // Emit mutation event for event-driven daemon s.emitMutation(MutationDelete, issueID, issue.Title, issue.Assignee) deletedCount++ } // Build response result := map[string]interface{}{ "deleted_count": deletedCount, "total_count": len(deleteArgs.IDs), } if len(errors) > 0 { result["errors"] = errors if deletedCount == 0 { // All deletes failed return Response{ Success: false, Error: fmt.Sprintf("failed to delete all issues: %v", errors), } } // Partial success result["partial_success"] = true } data, _ := json.Marshal(result) return Response{ Success: true, Data: data, } } func (s *Server) handleList(req *Request) Response { var listArgs ListArgs if err := json.Unmarshal(req.Args, &listArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid list 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)", } } filter := types.IssueFilter{ Limit: listArgs.Limit, } // Normalize status: treat "" or "all" as unset (no filter) if listArgs.Status != "" && listArgs.Status != "all" { status := types.Status(listArgs.Status) filter.Status = &status } if listArgs.IssueType != "" { issueType := types.IssueType(listArgs.IssueType) filter.IssueType = &issueType } if listArgs.Assignee != "" { filter.Assignee = &listArgs.Assignee } if listArgs.Priority != nil { filter.Priority = listArgs.Priority } // Normalize and apply label filters labels := util.NormalizeLabels(listArgs.Labels) labelsAny := util.NormalizeLabels(listArgs.LabelsAny) // Support both old single Label and new Labels array (backward compat) if len(labels) > 0 { filter.Labels = labels } else if listArgs.Label != "" { filter.Labels = []string{strings.TrimSpace(listArgs.Label)} } if len(labelsAny) > 0 { filter.LabelsAny = labelsAny } if len(listArgs.IDs) > 0 { ids := util.NormalizeLabels(listArgs.IDs) if len(ids) > 0 { filter.IDs = ids } } // Pattern matching filter.TitleContains = listArgs.TitleContains filter.DescriptionContains = listArgs.DescriptionContains filter.NotesContains = listArgs.NotesContains // Date ranges - use parseTimeRPC helper for flexible formats if listArgs.CreatedAfter != "" { t, err := parseTimeRPC(listArgs.CreatedAfter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --created-after date: %v", err), } } filter.CreatedAfter = &t } if listArgs.CreatedBefore != "" { t, err := parseTimeRPC(listArgs.CreatedBefore) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --created-before date: %v", err), } } filter.CreatedBefore = &t } if listArgs.UpdatedAfter != "" { t, err := parseTimeRPC(listArgs.UpdatedAfter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --updated-after date: %v", err), } } filter.UpdatedAfter = &t } if listArgs.UpdatedBefore != "" { t, err := parseTimeRPC(listArgs.UpdatedBefore) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --updated-before date: %v", err), } } filter.UpdatedBefore = &t } if listArgs.ClosedAfter != "" { t, err := parseTimeRPC(listArgs.ClosedAfter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --closed-after date: %v", err), } } filter.ClosedAfter = &t } if listArgs.ClosedBefore != "" { t, err := parseTimeRPC(listArgs.ClosedBefore) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --closed-before date: %v", err), } } filter.ClosedBefore = &t } // Empty/null checks filter.EmptyDescription = listArgs.EmptyDescription filter.NoAssignee = listArgs.NoAssignee filter.NoLabels = listArgs.NoLabels // Priority range filter.PriorityMin = listArgs.PriorityMin filter.PriorityMax = listArgs.PriorityMax // Pinned filtering filter.Pinned = listArgs.Pinned // Template filtering: exclude templates by default if !listArgs.IncludeTemplates { isTemplate := false filter.IsTemplate = &isTemplate } // Parent filtering if listArgs.ParentID != "" { filter.ParentID = &listArgs.ParentID } // Ephemeral filtering filter.Ephemeral = listArgs.Ephemeral // Molecule type filtering if listArgs.MolType != "" { molType := types.MolType(listArgs.MolType) filter.MolType = &molType } // Guard against excessive ID lists to avoid SQLite parameter limits const maxIDs = 1000 if len(filter.IDs) > maxIDs { return Response{ Success: false, Error: fmt.Sprintf("--id flag supports at most %d issue IDs, got %d", maxIDs, len(filter.IDs)), } } ctx := s.reqCtx(req) issues, err := store.SearchIssues(ctx, listArgs.Query, filter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to list issues: %v", err), } } // Populate labels for each issue for _, issue := range issues { labels, _ := store.GetLabels(ctx, issue.ID) issue.Labels = labels } // Get dependency counts in bulk (single query instead of N queries) issueIDs := make([]string, len(issues)) for i, issue := range issues { issueIDs[i] = issue.ID } depCounts, _ := store.GetDependencyCounts(ctx, issueIDs) // Build response with counts issuesWithCounts := make([]*types.IssueWithCounts, len(issues)) for i, issue := range issues { counts := depCounts[issue.ID] if counts == nil { counts = &types.DependencyCounts{DependencyCount: 0, DependentCount: 0} } issuesWithCounts[i] = &types.IssueWithCounts{ Issue: issue, DependencyCount: counts.DependencyCount, DependentCount: counts.DependentCount, } } data, _ := json.Marshal(issuesWithCounts) return Response{ Success: true, Data: data, } } func (s *Server) handleCount(req *Request) Response { var countArgs CountArgs if err := json.Unmarshal(req.Args, &countArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid count 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)", } } filter := types.IssueFilter{} // Normalize status: treat "" or "all" as unset (no filter) if countArgs.Status != "" && countArgs.Status != "all" { status := types.Status(countArgs.Status) filter.Status = &status } if countArgs.IssueType != "" { issueType := types.IssueType(countArgs.IssueType) filter.IssueType = &issueType } if countArgs.Assignee != "" { filter.Assignee = &countArgs.Assignee } if countArgs.Priority != nil { filter.Priority = countArgs.Priority } // Normalize and apply label filters labels := util.NormalizeLabels(countArgs.Labels) labelsAny := util.NormalizeLabels(countArgs.LabelsAny) if len(labels) > 0 { filter.Labels = labels } if len(labelsAny) > 0 { filter.LabelsAny = labelsAny } if len(countArgs.IDs) > 0 { ids := util.NormalizeLabels(countArgs.IDs) if len(ids) > 0 { filter.IDs = ids } } // Pattern matching filter.TitleContains = countArgs.TitleContains filter.DescriptionContains = countArgs.DescriptionContains filter.NotesContains = countArgs.NotesContains // Date ranges - use parseTimeRPC helper for flexible formats if countArgs.CreatedAfter != "" { t, err := parseTimeRPC(countArgs.CreatedAfter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --created-after date: %v", err), } } filter.CreatedAfter = &t } if countArgs.CreatedBefore != "" { t, err := parseTimeRPC(countArgs.CreatedBefore) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --created-before date: %v", err), } } filter.CreatedBefore = &t } if countArgs.UpdatedAfter != "" { t, err := parseTimeRPC(countArgs.UpdatedAfter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --updated-after date: %v", err), } } filter.UpdatedAfter = &t } if countArgs.UpdatedBefore != "" { t, err := parseTimeRPC(countArgs.UpdatedBefore) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --updated-before date: %v", err), } } filter.UpdatedBefore = &t } if countArgs.ClosedAfter != "" { t, err := parseTimeRPC(countArgs.ClosedAfter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --closed-after date: %v", err), } } filter.ClosedAfter = &t } if countArgs.ClosedBefore != "" { t, err := parseTimeRPC(countArgs.ClosedBefore) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid --closed-before date: %v", err), } } filter.ClosedBefore = &t } // Empty/null checks filter.EmptyDescription = countArgs.EmptyDescription filter.NoAssignee = countArgs.NoAssignee filter.NoLabels = countArgs.NoLabels // Priority range filter.PriorityMin = countArgs.PriorityMin filter.PriorityMax = countArgs.PriorityMax ctx := s.reqCtx(req) issues, err := store.SearchIssues(ctx, countArgs.Query, filter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to count issues: %v", err), } } // If no grouping, just return the count if countArgs.GroupBy == "" { type CountResult struct { Count int `json:"count"` } data, _ := json.Marshal(CountResult{Count: len(issues)}) return Response{ Success: true, Data: data, } } // Group by the specified field type GroupCount struct { Group string `json:"group"` Count int `json:"count"` } counts := make(map[string]int) // For label grouping, fetch all labels in one query to avoid N+1 var labelsMap map[string][]string if countArgs.GroupBy == "label" { issueIDs := make([]string, len(issues)) for i, issue := range issues { issueIDs[i] = issue.ID } var err error labelsMap, err = store.GetLabelsForIssues(ctx, issueIDs) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get labels: %v", err), } } } for _, issue := range issues { var groupKey string switch countArgs.GroupBy { case "status": groupKey = string(issue.Status) case "priority": groupKey = fmt.Sprintf("P%d", issue.Priority) case "type": groupKey = string(issue.IssueType) case "assignee": if issue.Assignee == "" { groupKey = "(unassigned)" } else { groupKey = issue.Assignee } case "label": // For labels, count each label separately labels := labelsMap[issue.ID] if len(labels) > 0 { for _, label := range labels { counts[label]++ } continue } else { groupKey = "(no labels)" } default: return Response{ Success: false, Error: fmt.Sprintf("invalid group_by value: %s (must be one of: status, priority, type, assignee, label)", countArgs.GroupBy), } } counts[groupKey]++ } // Convert map to sorted slice groups := make([]GroupCount, 0, len(counts)) for group, count := range counts { groups = append(groups, GroupCount{Group: group, Count: count}) } type GroupedCountResult struct { Total int `json:"total"` Groups []GroupCount `json:"groups"` } result := GroupedCountResult{ Total: len(issues), Groups: groups, } data, _ := json.Marshal(result) return Response{ Success: true, Data: data, } } func (s *Server) handleResolveID(req *Request) Response { var args ResolveIDArgs if err := json.Unmarshal(req.Args, &args); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid resolve_id args: %v", err), } } if s.storage == nil { return Response{ Success: false, Error: "storage not available (global daemon deprecated - use local daemon instead with 'bd daemon' in your project)", } } ctx := s.reqCtx(req) resolvedID, err := utils.ResolvePartialID(ctx, s.storage, args.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to resolve ID: %v", err), } } data, _ := json.Marshal(resolvedID) return Response{ Success: true, Data: data, } } func (s *Server) handleShow(req *Request) Response { var showArgs ShowArgs if err := json.Unmarshal(req.Args, &showArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid show 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)", } } ctx := s.reqCtx(req) issue, err := store.GetIssue(ctx, showArgs.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get issue: %v", err), } } if issue == nil { return Response{ Success: false, Error: fmt.Sprintf("issue not found: %s", showArgs.ID), } } // Populate labels, dependencies (with metadata), and dependents (with metadata) labels, _ := store.GetLabels(ctx, issue.ID) // Get dependencies and dependents with metadata (including dependency type) var deps []*types.IssueWithDependencyMetadata var dependents []*types.IssueWithDependencyMetadata if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { deps, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID) dependents, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID) } else { // Fallback for non-SQLite storage (won't have dependency type metadata) regularDeps, _ := store.GetDependencies(ctx, issue.ID) for _, d := range regularDeps { deps = append(deps, &types.IssueWithDependencyMetadata{ Issue: *d, DependencyType: types.DepBlocks, // default }) } regularDependents, _ := store.GetDependents(ctx, issue.ID) for _, d := range regularDependents { dependents = append(dependents, &types.IssueWithDependencyMetadata{ Issue: *d, DependencyType: types.DepBlocks, // default }) } } // Fetch comments comments, _ := store.GetIssueComments(ctx, issue.ID) // Create detailed response with related data type IssueDetails struct { *types.Issue Labels []string `json:"labels,omitempty"` Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"` Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"` Comments []*types.Comment `json:"comments,omitempty"` } details := &IssueDetails{ Issue: issue, Labels: labels, Dependencies: deps, Dependents: dependents, Comments: comments, } data, _ := json.Marshal(details) return Response{ Success: true, Data: data, } } func (s *Server) handleReady(req *Request) Response { var readyArgs ReadyArgs if err := json.Unmarshal(req.Args, &readyArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid ready 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)", } } wf := types.WorkFilter{ Status: types.StatusOpen, Type: readyArgs.Type, Priority: readyArgs.Priority, Unassigned: readyArgs.Unassigned, Limit: readyArgs.Limit, SortPolicy: types.SortPolicy(readyArgs.SortPolicy), Labels: util.NormalizeLabels(readyArgs.Labels), LabelsAny: util.NormalizeLabels(readyArgs.LabelsAny), } if readyArgs.Assignee != "" && !readyArgs.Unassigned { wf.Assignee = &readyArgs.Assignee } if readyArgs.ParentID != "" { wf.ParentID = &readyArgs.ParentID } if readyArgs.MolType != "" { molType := types.MolType(readyArgs.MolType) wf.MolType = &molType } ctx := s.reqCtx(req) issues, err := store.GetReadyWork(ctx, wf) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get ready work: %v", err), } } data, _ := json.Marshal(issues) return Response{ Success: true, Data: data, } } func (s *Server) handleBlocked(req *Request) Response { var blockedArgs BlockedArgs if err := json.Unmarshal(req.Args, &blockedArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid blocked 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)", } } var wf types.WorkFilter if blockedArgs.ParentID != "" { wf.ParentID = &blockedArgs.ParentID } ctx := s.reqCtx(req) blocked, err := store.GetBlockedIssues(ctx, wf) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get blocked issues: %v", err), } } data, _ := json.Marshal(blocked) return Response{ Success: true, Data: data, } } func (s *Server) handleStale(req *Request) Response { var staleArgs StaleArgs if err := json.Unmarshal(req.Args, &staleArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid stale 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)", } } filter := types.StaleFilter{ Days: staleArgs.Days, Status: staleArgs.Status, Limit: staleArgs.Limit, } ctx := s.reqCtx(req) issues, err := store.GetStaleIssues(ctx, filter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get stale issues: %v", err), } } data, _ := json.Marshal(issues) return Response{ Success: true, Data: data, } } func (s *Server) handleStats(req *Request) Response { 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)", } } ctx := s.reqCtx(req) stats, err := store.GetStatistics(ctx) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get statistics: %v", err), } } data, _ := json.Marshal(stats) return Response{ Success: true, Data: data, } } func (s *Server) handleEpicStatus(req *Request) Response { var epicArgs EpicStatusArgs if err := json.Unmarshal(req.Args, &epicArgs); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid epic status 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)", } } ctx := s.reqCtx(req) epics, err := store.GetEpicsEligibleForClosure(ctx) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get epic status: %v", err), } } if epicArgs.EligibleOnly { filtered := []*types.EpicStatus{} for _, epic := range epics { if epic.EligibleForClose { filtered = append(filtered, epic) } } epics = filtered } data, err := json.Marshal(epics) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to marshal epics: %v", err), } } return Response{ Success: true, Data: data, } } // Gate handlers func (s *Server) handleGateCreate(req *Request) Response { var args GateCreateArgs if err := json.Unmarshal(req.Args, &args); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid gate create args: %v", err), } } store := s.storage if store == nil { return Response{ Success: false, Error: "storage not available", } } ctx := s.reqCtx(req) now := time.Now() // Create gate issue gate := &types.Issue{ Title: args.Title, IssueType: types.TypeGate, Status: types.StatusOpen, Priority: 1, // Gates are typically high priority Assignee: "deacon/", Ephemeral: true, // Gates are wisps (ephemeral) AwaitType: args.AwaitType, AwaitID: args.AwaitID, Timeout: args.Timeout, Waiters: args.Waiters, CreatedAt: now, UpdatedAt: now, } gate.ContentHash = gate.ComputeContentHash() if err := store.CreateIssue(ctx, gate, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to create gate: %v", err), } } // Emit mutation event s.emitMutation(MutationCreate, gate.ID, gate.Title, gate.Assignee) data, _ := json.Marshal(GateCreateResult{ID: gate.ID}) return Response{ Success: true, Data: data, } } func (s *Server) handleGateList(req *Request) Response { var args GateListArgs if err := json.Unmarshal(req.Args, &args); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid gate list args: %v", err), } } store := s.storage if store == nil { return Response{ Success: false, Error: "storage not available", } } ctx := s.reqCtx(req) // Build filter for gates gateType := types.TypeGate filter := types.IssueFilter{ IssueType: &gateType, } if !args.All { openStatus := types.StatusOpen filter.Status = &openStatus } gates, err := store.SearchIssues(ctx, "", filter) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to list gates: %v", err), } } data, _ := json.Marshal(gates) return Response{ Success: true, Data: data, } } func (s *Server) handleGateShow(req *Request) Response { var args GateShowArgs if err := json.Unmarshal(req.Args, &args); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid gate show args: %v", err), } } store := s.storage if store == nil { return Response{ Success: false, Error: "storage not available", } } ctx := s.reqCtx(req) // Resolve partial ID gateID, err := utils.ResolvePartialID(ctx, store, args.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to resolve gate ID: %v", err), } } gate, err := store.GetIssue(ctx, gateID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get gate: %v", err), } } if gate == nil { return Response{ Success: false, Error: fmt.Sprintf("gate %s not found", gateID), } } if gate.IssueType != types.TypeGate { return Response{ Success: false, Error: fmt.Sprintf("%s is not a gate (type: %s)", gateID, gate.IssueType), } } data, _ := json.Marshal(gate) return Response{ Success: true, Data: data, } } func (s *Server) handleGateClose(req *Request) Response { var args GateCloseArgs if err := json.Unmarshal(req.Args, &args); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid gate close args: %v", err), } } store := s.storage if store == nil { return Response{ Success: false, Error: "storage not available", } } ctx := s.reqCtx(req) // Resolve partial ID gateID, err := utils.ResolvePartialID(ctx, store, args.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to resolve gate ID: %v", err), } } // Verify it's a gate gate, err := store.GetIssue(ctx, gateID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get gate: %v", err), } } if gate == nil { return Response{ Success: false, Error: fmt.Sprintf("gate %s not found", gateID), } } if gate.IssueType != types.TypeGate { return Response{ Success: false, Error: fmt.Sprintf("%s is not a gate (type: %s)", gateID, gate.IssueType), } } reason := args.Reason if reason == "" { reason = "Gate closed" } oldStatus := string(gate.Status) if err := store.CloseIssue(ctx, gateID, reason, s.reqActor(req)); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to close gate: %v", err), } } // Emit rich status change event s.emitRichMutation(MutationEvent{ Type: MutationStatus, IssueID: gateID, OldStatus: oldStatus, NewStatus: "closed", }) closedGate, _ := store.GetIssue(ctx, gateID) data, _ := json.Marshal(closedGate) return Response{ Success: true, Data: data, } } func (s *Server) handleGateWait(req *Request) Response { var args GateWaitArgs if err := json.Unmarshal(req.Args, &args); err != nil { return Response{ Success: false, Error: fmt.Sprintf("invalid gate wait args: %v", err), } } store := s.storage if store == nil { return Response{ Success: false, Error: "storage not available", } } ctx := s.reqCtx(req) // Resolve partial ID gateID, err := utils.ResolvePartialID(ctx, store, args.ID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to resolve gate ID: %v", err), } } // Get existing gate gate, err := store.GetIssue(ctx, gateID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to get gate: %v", err), } } if gate == nil { return Response{ Success: false, Error: fmt.Sprintf("gate %s not found", gateID), } } if gate.IssueType != types.TypeGate { return Response{ Success: false, Error: fmt.Sprintf("%s is not a gate (type: %s)", gateID, gate.IssueType), } } if gate.Status == types.StatusClosed { return Response{ Success: false, Error: fmt.Sprintf("gate %s is already closed", gateID), } } // Add new waiters (avoiding duplicates) waiterSet := make(map[string]bool) for _, w := range gate.Waiters { waiterSet[w] = true } newWaiters := []string{} for _, addr := range args.Waiters { if !waiterSet[addr] { newWaiters = append(newWaiters, addr) waiterSet[addr] = true } } addedCount := len(newWaiters) if addedCount > 0 { // Update waiters using SQLite directly sqliteStore, ok := store.(*sqlite.SQLiteStorage) if !ok { return Response{ Success: false, Error: "gate wait requires SQLite storage", } } allWaiters := append(gate.Waiters, newWaiters...) waitersJSON, _ := json.Marshal(allWaiters) // Use raw SQL to update the waiters field _, err = sqliteStore.UnderlyingDB().ExecContext(ctx, `UPDATE issues SET waiters = ?, updated_at = ? WHERE id = ?`, string(waitersJSON), time.Now(), gateID) if err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to add waiters: %v", err), } } // Emit mutation event s.emitMutation(MutationUpdate, gateID, gate.Title, gate.Assignee) } data, _ := json.Marshal(GateWaitResult{AddedCount: addedCount}) return Response{ Success: true, Data: data, } }