// Package main implements the bd CLI state management commands. // These commands provide convenient access to the labels-as-state pattern // documented in docs/LABELS.md. package main import ( "context" "encoding/json" "fmt" "strings" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var stateCmd = &cobra.Command{ Use: "state ", GroupID: "issues", Short: "Query the current value of a state dimension", Long: `Query the current value of a state dimension from an issue's labels. State labels follow the convention :, for example: patrol:active mode:degraded health:healthy This command extracts the value for a given dimension. Examples: bd state witness-abc patrol # Output: active bd state witness-abc mode # Output: normal bd state witness-abc health # Output: healthy`, Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { ctx := rootCtx issueID := args[0] dimension := args[1] // Resolve partial ID var fullID string if daemonClient != nil { resolveArgs := &rpc.ResolveIDArgs{ID: issueID} resp, err := daemonClient.ResolveID(resolveArgs) if err != nil { FatalErrorRespectJSON("resolving issue ID %s: %v", issueID, err) } if err := json.Unmarshal(resp.Data, &fullID); err != nil { FatalErrorRespectJSON("unmarshaling resolved ID: %v", err) } } else { var err error fullID, err = utils.ResolvePartialID(ctx, store, issueID) if err != nil { FatalErrorRespectJSON("resolving %s: %v", issueID, err) } } // Get labels for the issue var labels []string if daemonClient != nil { resp, err := daemonClient.Show(&rpc.ShowArgs{ID: fullID}) if err != nil { FatalErrorRespectJSON("%v", err) } var issue types.Issue if err := json.Unmarshal(resp.Data, &issue); err != nil { FatalErrorRespectJSON("parsing response: %v", err) } labels = issue.Labels } else { var err error labels, err = store.GetLabels(ctx, fullID) if err != nil { FatalErrorRespectJSON("%v", err) } } // Find label matching dimension:* prefix := dimension + ":" var value string for _, label := range labels { if strings.HasPrefix(label, prefix) { value = strings.TrimPrefix(label, prefix) break } } if jsonOutput { result := map[string]interface{}{ "issue_id": fullID, "dimension": dimension, "value": value, } if value == "" { result["value"] = nil } outputJSON(result) return } if value == "" { fmt.Printf("(no %s state set)\n", dimension) } else { fmt.Println(value) } }, } var setStateCmd = &cobra.Command{ Use: "set-state =", GroupID: "issues", Short: "Set operational state (creates event + updates label)", Long: `Atomically set operational state on an issue. This command: 1. Creates an event bead recording the state change (source of truth) 2. Removes any existing label for the dimension 3. Adds the new dimension:value label (fast lookup cache) State labels follow the convention :, for example: patrol:active, patrol:muted mode:normal, mode:degraded health:healthy, health:failing Examples: bd set-state witness-abc patrol=muted --reason "Investigating stuck polecat" bd set-state witness-abc mode=degraded --reason "High error rate detected" bd set-state witness-abc health=healthy The --reason flag provides context for the event bead (recommended).`, Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("set-state") ctx := rootCtx issueID := args[0] stateSpec := args[1] // Parse dimension=value parts := strings.SplitN(stateSpec, "=", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { FatalErrorRespectJSON("invalid state format %q, expected =", stateSpec) } dimension := parts[0] newValue := parts[1] reason, _ := cmd.Flags().GetString("reason") // Resolve partial ID var fullID string if daemonClient != nil { resolveArgs := &rpc.ResolveIDArgs{ID: issueID} resp, err := daemonClient.ResolveID(resolveArgs) if err != nil { FatalErrorRespectJSON("resolving issue ID %s: %v", issueID, err) } if err := json.Unmarshal(resp.Data, &fullID); err != nil { FatalErrorRespectJSON("unmarshaling resolved ID: %v", err) } } else { var err error fullID, err = utils.ResolvePartialID(ctx, store, issueID) if err != nil { FatalErrorRespectJSON("resolving %s: %v", issueID, err) } } // Get current labels to find existing dimension value var labels []string if daemonClient != nil { resp, err := daemonClient.Show(&rpc.ShowArgs{ID: fullID}) if err != nil { FatalErrorRespectJSON("%v", err) } var issue types.Issue if err := json.Unmarshal(resp.Data, &issue); err != nil { FatalErrorRespectJSON("parsing response: %v", err) } labels = issue.Labels } else { var err error labels, err = store.GetLabels(ctx, fullID) if err != nil { FatalErrorRespectJSON("%v", err) } } // Find existing label for this dimension prefix := dimension + ":" var oldLabel string var oldValue string for _, label := range labels { if strings.HasPrefix(label, prefix) { oldLabel = label oldValue = strings.TrimPrefix(label, prefix) break } } newLabel := dimension + ":" + newValue // Skip if no change if oldLabel == newLabel { if jsonOutput { outputJSON(map[string]interface{}{ "issue_id": fullID, "dimension": dimension, "value": newValue, "changed": false, }) } else { fmt.Printf("(no change: %s already set to %s)\n", dimension, newValue) } return } // 1. Create event bead recording the state change eventTitle := fmt.Sprintf("State change: %s → %s", dimension, newValue) eventDesc := "" if oldValue != "" { eventDesc = fmt.Sprintf("Changed %s from %s to %s", dimension, oldValue, newValue) } else { eventDesc = fmt.Sprintf("Set %s to %s", dimension, newValue) } if reason != "" { eventDesc += "\n\nReason: " + reason } var eventID string if daemonClient != nil { createArgs := &rpc.CreateArgs{ Parent: fullID, Title: eventTitle, Description: eventDesc, IssueType: "event", Priority: 4, // Low priority for events CreatedBy: getActorWithGit(), } resp, err := daemonClient.Create(createArgs) if err != nil { FatalErrorRespectJSON("creating event: %v", err) } var issue types.Issue if err := json.Unmarshal(resp.Data, &issue); err != nil { FatalErrorRespectJSON("parsing event response: %v", err) } eventID = issue.ID } else { // Get next child ID for the event childID, err := store.GetNextChildID(ctx, fullID) if err != nil { FatalErrorRespectJSON("generating child ID: %v", err) } event := &types.Issue{ ID: childID, Title: eventTitle, Description: eventDesc, Status: types.StatusClosed, // Events are immediately closed Priority: 4, IssueType: types.IssueType("event"), CreatedBy: getActorWithGit(), } if err := store.CreateIssue(ctx, event, actor); err != nil { FatalErrorRespectJSON("creating event: %v", err) } // Add parent-child dependency dep := &types.Dependency{ IssueID: childID, DependsOnID: fullID, Type: types.DepParentChild, } if err := store.AddDependency(ctx, dep, actor); err != nil { WarnError("failed to add parent-child dependency: %v", err) } eventID = childID } // 2. Remove old label if exists if oldLabel != "" { if daemonClient != nil { _, err := daemonClient.RemoveLabel(&rpc.LabelRemoveArgs{ID: fullID, Label: oldLabel}) if err != nil { WarnError("failed to remove old label %s: %v", oldLabel, err) } } else { if err := store.RemoveLabel(ctx, fullID, oldLabel, actor); err != nil { WarnError("failed to remove old label %s: %v", oldLabel, err) } } } // 3. Add new label if daemonClient != nil { _, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: fullID, Label: newLabel}) if err != nil { FatalErrorRespectJSON("adding label: %v", err) } } else { if err := store.AddLabel(ctx, fullID, newLabel, actor); err != nil { FatalErrorRespectJSON("adding label: %v", err) } } // Schedule auto-flush if in direct mode if daemonClient == nil { markDirtyAndScheduleFlush() } if jsonOutput { result := map[string]interface{}{ "issue_id": fullID, "dimension": dimension, "old_value": oldValue, "new_value": newValue, "event_id": eventID, "changed": true, } if oldValue == "" { result["old_value"] = nil } outputJSON(result) return } fmt.Printf("%s Set %s = %s on %s\n", ui.RenderPass("✓"), dimension, newValue, fullID) if oldValue != "" { fmt.Printf(" Previous: %s\n", oldValue) } fmt.Printf(" Event: %s\n", eventID) }, } // stateListCmd lists all state dimensions on an issue var stateListCmd = &cobra.Command{ Use: "list ", Short: "List all state dimensions on an issue", Long: `List all state labels (dimension:value format) on an issue. This filters labels to only show those following the state convention. Example: bd state list witness-abc # Output: # patrol: active # mode: normal # health: healthy`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { ctx := rootCtx issueID := args[0] // Resolve partial ID var fullID string if daemonClient != nil { resolveArgs := &rpc.ResolveIDArgs{ID: issueID} resp, err := daemonClient.ResolveID(resolveArgs) if err != nil { FatalErrorRespectJSON("resolving issue ID %s: %v", issueID, err) } if err := json.Unmarshal(resp.Data, &fullID); err != nil { FatalErrorRespectJSON("unmarshaling resolved ID: %v", err) } } else { var err error fullID, err = utils.ResolvePartialID(ctx, store, issueID) if err != nil { FatalErrorRespectJSON("resolving %s: %v", issueID, err) } } // Get labels for the issue var labels []string if daemonClient != nil { resp, err := daemonClient.Show(&rpc.ShowArgs{ID: fullID}) if err != nil { FatalErrorRespectJSON("%v", err) } var issue types.Issue if err := json.Unmarshal(resp.Data, &issue); err != nil { FatalErrorRespectJSON("parsing response: %v", err) } labels = issue.Labels } else { var err error labels, err = store.GetLabels(ctx, fullID) if err != nil { FatalErrorRespectJSON("%v", err) } } // Extract state labels (those with colon) states := make(map[string]string) for _, label := range labels { if idx := strings.Index(label, ":"); idx > 0 { dimension := label[:idx] value := label[idx+1:] states[dimension] = value } } if jsonOutput { result := map[string]interface{}{ "issue_id": fullID, "states": states, } outputJSON(result) return } if len(states) == 0 { fmt.Printf("\n%s has no state labels\n", fullID) return } fmt.Printf("\n%s State for %s:\n", ui.RenderAccent("📊"), fullID) for dimension, value := range states { fmt.Printf(" %s: %s\n", dimension, value) } fmt.Println() }, } func init() { // set-state flags setStateCmd.Flags().String("reason", "", "Reason for the state change (recorded in event)") // Add subcommands stateCmd.AddCommand(stateListCmd) rootCmd.AddCommand(stateCmd) rootCmd.AddCommand(setStateCmd) } // Ensure ctx is available var _ context.Context = rootCtx