From 48dca4ea3337ef4cde26e1883d141e721d04bd1f Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 15:56:23 -0800 Subject: [PATCH] feat: add bd state and bd set-state helper commands (bd-7l67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements convenience commands for the labels-as-state pattern: - `bd state ` - Query current state value from labels - `bd state list ` - List all state dimensions on an issue - `bd set-state = --reason "..."` - Atomically: 1. Create event bead (source of truth) 2. Remove old dimension label 3. Add new dimension:value label (cache) Common dimensions: patrol, mode, health, status Convention: : (e.g., patrol:active, mode:degraded) Updated docs/CLI_REFERENCE.md with new State section. Updated docs/LABELS.md to reflect implemented helpers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/state.go | 440 ++++++++++++++++++++++++++++++++++++++++++ cmd/bd/state_test.go | 263 +++++++++++++++++++++++++ docs/CLI_REFERENCE.md | 32 +++ docs/LABELS.md | 18 +- 4 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 cmd/bd/state.go create mode 100644 cmd/bd/state_test.go diff --git a/cmd/bd/state.go b/cmd/bd/state.go new file mode 100644 index 00000000..8b3582d9 --- /dev/null +++ b/cmd/bd/state.go @@ -0,0 +1,440 @@ +// 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 diff --git a/cmd/bd/state_test.go b/cmd/bd/state_test.go new file mode 100644 index 00000000..f737ea16 --- /dev/null +++ b/cmd/bd/state_test.go @@ -0,0 +1,263 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +type stateTestHelper struct { + s *sqlite.SQLiteStorage + ctx context.Context + t *testing.T +} + +func (h *stateTestHelper) createIssue(title string, issueType types.IssueType, priority int) *types.Issue { + issue := &types.Issue{ + Title: title, + Priority: priority, + IssueType: issueType, + Status: types.StatusOpen, + } + if err := h.s.CreateIssue(h.ctx, issue, "test-user"); err != nil { + h.t.Fatalf("Failed to create issue: %v", err) + } + return issue +} + +func (h *stateTestHelper) addLabel(issueID, label string) { + if err := h.s.AddLabel(h.ctx, issueID, label, "test-user"); err != nil { + h.t.Fatalf("Failed to add label '%s': %v", label, err) + } +} + +func (h *stateTestHelper) removeLabel(issueID, label string) { + if err := h.s.RemoveLabel(h.ctx, issueID, label, "test-user"); err != nil { + h.t.Fatalf("Failed to remove label '%s': %v", label, err) + } +} + +func (h *stateTestHelper) getLabels(issueID string) []string { + labels, err := h.s.GetLabels(h.ctx, issueID) + if err != nil { + h.t.Fatalf("Failed to get labels: %v", err) + } + return labels +} + +// getStateValue extracts the value for a dimension from labels +func (h *stateTestHelper) getStateValue(issueID, dimension string) string { + labels := h.getLabels(issueID) + prefix := dimension + ":" + for _, label := range labels { + if strings.HasPrefix(label, prefix) { + return strings.TrimPrefix(label, prefix) + } + } + return "" +} + +// getStates extracts all dimension:value labels as a map +func (h *stateTestHelper) getStates(issueID string) map[string]string { + labels := h.getLabels(issueID) + 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 + } + } + return states +} + +func (h *stateTestHelper) assertStateValue(issueID, dimension, expected string) { + actual := h.getStateValue(issueID, dimension) + if actual != expected { + h.t.Errorf("Expected %s=%s, got %s=%s", dimension, expected, dimension, actual) + } +} + +func (h *stateTestHelper) assertNoState(issueID, dimension string) { + value := h.getStateValue(issueID, dimension) + if value != "" { + h.t.Errorf("Expected no %s state, got %s", dimension, value) + } +} + +func (h *stateTestHelper) assertStateCount(issueID string, expected int) { + states := h.getStates(issueID) + if len(states) != expected { + h.t.Errorf("Expected %d states, got %d: %v", expected, len(states), states) + } +} + +func TestStateQueries(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "bd-test-state-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + testDB := filepath.Join(tmpDir, "test.db") + s := newTestStore(t, testDB) + defer s.Close() + + ctx := context.Background() + h := &stateTestHelper{s: s, ctx: ctx, t: t} + + t.Run("query state from label", func(t *testing.T) { + issue := h.createIssue("Role Test", types.TypeTask, 1) + h.addLabel(issue.ID, "patrol:active") + h.assertStateValue(issue.ID, "patrol", "active") + }) + + t.Run("query multiple states", func(t *testing.T) { + issue := h.createIssue("Multi State Test", types.TypeTask, 1) + h.addLabel(issue.ID, "patrol:active") + h.addLabel(issue.ID, "mode:normal") + h.addLabel(issue.ID, "health:healthy") + h.assertStateValue(issue.ID, "patrol", "active") + h.assertStateValue(issue.ID, "mode", "normal") + h.assertStateValue(issue.ID, "health", "healthy") + h.assertStateCount(issue.ID, 3) + }) + + t.Run("query missing state returns empty", func(t *testing.T) { + issue := h.createIssue("No State Test", types.TypeTask, 1) + h.assertNoState(issue.ID, "patrol") + }) + + t.Run("state labels mixed with regular labels", func(t *testing.T) { + issue := h.createIssue("Mixed Labels Test", types.TypeTask, 1) + h.addLabel(issue.ID, "patrol:active") + h.addLabel(issue.ID, "backend") // Not a state label + h.addLabel(issue.ID, "mode:normal") + h.addLabel(issue.ID, "urgent") // Not a state label + h.assertStateValue(issue.ID, "patrol", "active") + h.assertStateValue(issue.ID, "mode", "normal") + h.assertStateCount(issue.ID, 2) + }) + + t.Run("state with colon in value", func(t *testing.T) { + issue := h.createIssue("Colon Value Test", types.TypeTask, 1) + h.addLabel(issue.ID, "error:code:500") + value := h.getStateValue(issue.ID, "error") + if value != "code:500" { + t.Errorf("Expected 'code:500', got '%s'", value) + } + }) +} + +func TestStateTransitions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "bd-test-state-transition-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + testDB := filepath.Join(tmpDir, "test.db") + s := newTestStore(t, testDB) + defer s.Close() + + ctx := context.Background() + h := &stateTestHelper{s: s, ctx: ctx, t: t} + + t.Run("change state value", func(t *testing.T) { + issue := h.createIssue("Transition Test", types.TypeTask, 1) + + // Initial state + h.addLabel(issue.ID, "patrol:active") + h.assertStateValue(issue.ID, "patrol", "active") + + // Transition to muted (remove old, add new) + h.removeLabel(issue.ID, "patrol:active") + h.addLabel(issue.ID, "patrol:muted") + h.assertStateValue(issue.ID, "patrol", "muted") + }) + + t.Run("prevent duplicate dimension values", func(t *testing.T) { + issue := h.createIssue("Duplicate Prevention Test", types.TypeTask, 1) + + // Add initial state + h.addLabel(issue.ID, "patrol:active") + + // If we add another value without removing, we'd have both + // This is what the set-state command prevents + h.addLabel(issue.ID, "patrol:muted") + + // Now we have both - this is the anti-pattern + labels := h.getLabels(issue.ID) + count := 0 + for _, l := range labels { + if strings.HasPrefix(l, "patrol:") { + count++ + } + } + if count != 2 { + t.Errorf("Expected 2 patrol labels (anti-pattern), got %d", count) + } + + // The getStateValue only returns the first one found + // This demonstrates why proper transitions (remove then add) are needed + }) +} + +func TestStatePatterns(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "bd-test-state-patterns-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + testDB := filepath.Join(tmpDir, "test.db") + s := newTestStore(t, testDB) + defer s.Close() + + ctx := context.Background() + h := &stateTestHelper{s: s, ctx: ctx, t: t} + + t.Run("common operational dimensions", func(t *testing.T) { + issue := h.createIssue("Operations Role", types.TypeTask, 1) + + // Set up typical operational state + h.addLabel(issue.ID, "patrol:active") + h.addLabel(issue.ID, "mode:normal") + h.addLabel(issue.ID, "health:healthy") + h.addLabel(issue.ID, "sync:current") + + states := h.getStates(issue.ID) + expected := map[string]string{ + "patrol": "active", + "mode": "normal", + "health": "healthy", + "sync": "current", + } + + for dim, val := range expected { + if states[dim] != val { + t.Errorf("Expected %s=%s, got %s=%s", dim, val, dim, states[dim]) + } + } + }) + + t.Run("degraded mode example", func(t *testing.T) { + issue := h.createIssue("Degraded Role", types.TypeTask, 1) + + // Start healthy + h.addLabel(issue.ID, "health:healthy") + h.addLabel(issue.ID, "mode:normal") + + // Degrade + h.removeLabel(issue.ID, "mode:normal") + h.addLabel(issue.ID, "mode:degraded") + + h.assertStateValue(issue.ID, "mode", "degraded") + h.assertStateValue(issue.ID, "health", "healthy") // Health unchanged + }) +} diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index a7771270..45a3decb 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -143,6 +143,38 @@ bd label list --json bd label list-all --json ``` +### State (Labels as Cache) + +For operational state tracking on role beads. Uses `:` label convention. +See [LABELS.md](LABELS.md#operational-state-pattern-labels-as-cache) for full pattern documentation. + +```bash +# Query current state value +bd state # Output: value +bd state witness-abc patrol # Output: active +bd state --json witness-abc patrol # {"issue_id": "...", "dimension": "patrol", "value": "active"} + +# List all state dimensions on an issue +bd state list --json +bd state list witness-abc # patrol: active, mode: normal, health: healthy + +# Set state (creates event + updates label atomically) +bd set-state = --reason "explanation" --json +bd set-state witness-abc patrol=muted --reason "Investigating stuck polecat" +bd set-state witness-abc mode=degraded --reason "High error rate" +``` + +**Common dimensions:** +- `patrol`: active, muted, suspended +- `mode`: normal, degraded, maintenance +- `health`: healthy, warning, failing +- `status`: idle, working, blocked + +**What `set-state` does:** +1. Creates event bead with reason (source of truth) +2. Removes old `:*` label if exists +3. Adds new `:` label (cache) + ## Filtering & Search ### Basic Filters diff --git a/docs/LABELS.md b/docs/LABELS.md index d0af89b3..ef5b0df1 100644 --- a/docs/LABELS.md +++ b/docs/LABELS.md @@ -714,20 +714,32 @@ bd label list witness-alpha # Output: patrol:active, mode:normal, health:healthy ``` -### Future: Helper Commands +### Helper Commands -For convenience, these helpers are planned: +For convenience, use these helpers: ```bash # Query a specific dimension bd state witness-alpha patrol # Output: active +# List all state dimensions +bd state list witness-alpha +# Output: +# patrol: active +# mode: normal +# health: healthy + # Set state (creates event + updates label atomically) bd set-state witness-alpha patrol=muted --reason "Investigating issue" ``` -Until then, use the manual event + label pattern above. +The `set-state` command atomically: +1. Creates an event bead with the reason (source of truth) +2. Removes the old dimension label if present +3. Adds the new dimension:value label (cache) + +See [CLI_REFERENCE.md](CLI_REFERENCE.md#state-labels-as-cache) for full command reference. ## Troubleshooting