feat: add bd state and bd set-state helper commands (bd-7l67)

Implements convenience commands for the labels-as-state pattern:

- `bd state <id> <dimension>` - Query current state value from labels
- `bd state list <id>` - List all state dimensions on an issue
- `bd set-state <id> <dimension>=<value> --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: <dimension>:<value> (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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-30 15:56:23 -08:00
parent a534764315
commit 48dca4ea33
4 changed files with 750 additions and 3 deletions

440
cmd/bd/state.go Normal file
View File

@@ -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 <issue-id> <dimension>",
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 <dimension>:<value>, 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 <issue-id> <dimension>=<value>",
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 <dimension>:<value>, 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 <dimension>=<value>", 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 <issue-id>",
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

263
cmd/bd/state_test.go Normal file
View File

@@ -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
})
}

View File

@@ -143,6 +143,38 @@ bd label list <id> --json
bd label list-all --json
```
### State (Labels as Cache)
For operational state tracking on role beads. Uses `<dimension>:<value>` label convention.
See [LABELS.md](LABELS.md#operational-state-pattern-labels-as-cache) for full pattern documentation.
```bash
# Query current state value
bd state <id> <dimension> # 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 <id> --json
bd state list witness-abc # patrol: active, mode: normal, health: healthy
# Set state (creates event + updates label atomically)
bd set-state <id> <dimension>=<value> --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 `<dimension>:*` label if exists
3. Adds new `<dimension>:<value>` label (cache)
## Filtering & Search
### Basic Filters

View File

@@ -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