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:
440
cmd/bd/state.go
Normal file
440
cmd/bd/state.go
Normal 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
263
cmd/bd/state_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user